diff --git a/contracts/token/XORO.sol b/contracts/token/XORO.sol index 8d72b2f..cad8713 100644 --- a/contracts/token/XORO.sol +++ b/contracts/token/XORO.sol @@ -3,9 +3,11 @@ pragma solidity 0.8.19; import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; import '@openzeppelin/contracts/access/Ownable.sol'; +import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; import '../libraries/Operatable.sol'; +import './XOROECDSA.sol'; -contract XORO is ERC20, Ownable, Operatable { +contract XORO is ERC20, Ownable, Operatable, XOROECDSA, ReentrancyGuard { // Error: Access denied error AccessDenied(); @@ -44,7 +46,7 @@ contract XORO is ERC20, Ownable, Operatable { //====================[ Operator ]==================== // Mint token in packed data - function batchMint(uint256[] calldata packedData) external onlyOperator returns(uint256) { + function batchMint(uint256[] calldata packedData) external onlyOperator returns (uint256) { for (uint i = 0; i < packedData.length; i += 1) { (uint96 amount, address to) = _unpack(packedData[i]); _mint(to, amount); @@ -53,7 +55,7 @@ contract XORO is ERC20, Ownable, Operatable { } // Burn token in packed data - function batchBurn(uint256[] calldata packedData) external onlyOperator returns(uint256) { + function batchBurn(uint256[] calldata packedData) external onlyOperator returns (uint256) { for (uint i = 0; i < packedData.length; i += 1) { (uint96 amount, address from) = _unpack(packedData[i]); _burn(from, amount); @@ -61,6 +63,36 @@ contract XORO is ERC20, Ownable, Operatable { return packedData.length; } + //====================[ User ]==================== + + // Every one can claim token with a valid ECDSA proof by Orochi Network + function redeem(bytes memory proof) external nonReentrant returns (uint256) { + XOROECDSAProof memory ecdsa = _decodeProof(proof); + + // Signer must be operator + if (!_isOperator(ecdsa.signer)) { + revert InvalidSignature(ecdsa.signer); + } + + // Nonce must be valid + if (!_verifyNonce(ecdsa.beneficiary, ecdsa.nonce)) { + revert InvalidNonce(ecdsa.beneficiary, ecdsa.nonce); + } + + // Chain Id must be correct + if (uint(ecdsa.chainId) != block.chainid) { + revert InvalidChain(ecdsa.chainId); + } + + // Mint the token for beneficiary + _mint(ecdsa.beneficiary, ecdsa.value); + + // Increase his nonce + _increaseNonce(ecdsa.beneficiary); + + return ecdsa.value; + } + //====================[ Disabled ]==================== /** diff --git a/contracts/token/XOROECDSA.sol b/contracts/token/XOROECDSA.sol new file mode 100644 index 0000000..17c2d73 --- /dev/null +++ b/contracts/token/XOROECDSA.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; +import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; +import '../libraries/Bytes.sol'; + +error InvalidSignature(address signer); +error InvalidNonce(address receiverAddress, uint64 nonce); +error InvalidChain(uint256 chainId); + +contract XOROECDSA { + // Using Bytes for bytes + using Bytes for bytes; + + // Verifiy digital signature + using ECDSA for bytes; + using ECDSA for bytes32; + + // Storage of recent epoch's result + mapping(address => uint256) private nonceStorage; + + // Structure of ECDSA proof of XORO + struct XOROECDSAProof { + address signer; + uint96 chainId; + address beneficiary; + uint64 nonce; + uint192 value; + } + + // Verify proof of operator + // uint96 chainId; + // address beneficiary; + // uint64 nonce; + // uint192 value; + function _decodeProof(bytes memory proof) internal pure returns (XOROECDSAProof memory ecdsaProof) { + bytes memory signature = proof.readBytes(0, 65); + bytes memory message = proof.readBytes(65, 64); + uint256 uin256Value = message.readUint256(0); + ecdsaProof.chainId = uint96(uin256Value >> 160); + ecdsaProof.beneficiary = address(uint160(uin256Value)); + uin256Value = message.readUint256(32); + ecdsaProof.nonce = uint64(uin256Value >> 192); + ecdsaProof.value = uint192(uin256Value); + ecdsaProof.signer = message.toEthSignedMessageHash().recover(signature); + return ecdsaProof; + } + + // Verify nonce + function _getNonce(address singerAddress) internal view returns (uint256) { + return nonceStorage[singerAddress]; + } + + // Verify nonce + function _verifyNonce(address receiverAddress, uint64 nonce) internal view returns (bool) { + return nonceStorage[receiverAddress] == nonce; + } + + // Increase nonce + function _increaseNonce(address receiverAddress) internal { + nonceStorage[receiverAddress] += 1; + } + + // Get nonce of a given address + function getNonce(address receiverAddress) external view returns (uint256) { + return _getNonce(receiverAddress); + } +} diff --git a/test/006-orocle.spec.ts b/test/006-orocle.spec.ts index 03a3c9a..13a7951 100644 --- a/test/006-orocle.spec.ts +++ b/test/006-orocle.spec.ts @@ -44,43 +44,43 @@ describe('OrocleV1', function () { const data = OrocleEncoding.encodeTokenPrice([ { symbol: 'BTC', - price: 42000n * 10n ** 9n, + price: 42000n * 10n ** 18n, }, { symbol: 'ETH', - price: 2000n * 10n ** 9n, + price: 2000n * 10n ** 18n, }, { symbol: 'DOT', - price: 6n * 10n ** 9n, + price: 6n * 10n ** 18n, }, { symbol: 'MINA', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USDT', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USDC', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USA', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USB', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USG', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, { symbol: 'USS', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, ]); @@ -93,19 +93,19 @@ describe('OrocleV1', function () { const data = OrocleEncoding.encodeTokenPrice([ { symbol: 'BTC', - price: 42000n * 10n ** 9n, + price: 42000n * 10n ** 18n, }, { symbol: 'ETH', - price: 2000n * 10n ** 9n, + price: 2000n * 10n ** 18n, }, { symbol: 'DOT', - price: 6n * 10n ** 9n, + price: 6n * 10n ** 18n, }, { symbol: 'MINA', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, ]); @@ -126,20 +126,20 @@ describe('OrocleV1', function () { const data = OrocleEncoding.encodeTokenPrice([ { symbol: 'BTC', - price: 42000n * 10n ** 9n, + price: 42000n * 10n ** 18n, }, { symbol: 'ETH', - price: 2000n * 10n ** 9n, + price: 2000n * 10n ** 18n, }, { symbol: 'DOT', - price: 6n * 10n ** 9n, + price: 6n * 10n ** 18n, }, { symbol: 'MINA', - price: 1n * 10n ** 9n, + price: 1n * 10n ** 18n, }, ...tokens, ]); diff --git a/test/008-orand-provider-v3.spec.ts b/test/008-orand-provider-v3.spec.ts index b103058..96749bb 100644 --- a/test/008-orand-provider-v3.spec.ts +++ b/test/008-orand-provider-v3.spec.ts @@ -395,18 +395,18 @@ describe('OrandProviderV3', function () { it('OrandProviderV3 should be upgradeable', async () => { const { ethers } = hre; const accounts = await ethers.getSigners(); - const orocleV1Factory = await ethers.getContractFactory('OrocleV1'); + const orocleV2Factory = await ethers.getContractFactory('OrocleV2'); const orandECVRFV3Factory = await ethers.getContractFactory('OrandECVRFV3'); - const orand1Factory = await ethers.getContractFactory('OrandProviderTest'); - const orand2Factory = await ethers.getContractFactory('OrandProviderV3'); + const orandV3Factory1 = await ethers.getContractFactory('OrandProviderTest'); + const orandV3Factory2 = await ethers.getContractFactory('OrandProviderV3'); - const orocleV1 = await orocleV1Factory.deploy([accounts[0]]); - console.log('Orocle V1', await orocleV1.getAddress()); + const orocleV2 = await upgrades.deployProxy(orocleV2Factory, [[accounts[0].address]]); + console.log('Orocle V2', await orocleV2.getAddress()); let correspondingAddress = getAddress(`0x${keccak256(`0x${pk.substring(2, 130)}`).substring(26, 66)}`); const orandECVRFV3 = await orandECVRFV3Factory.deploy(); console.log('Orand ECVRF V3', await orandECVRFV3.getAddress()); - const instance = await upgrades.deployProxy(orand1Factory, [ + const instance = await upgrades.deployProxy(orandV3Factory1, [ // uint256[2] memory publicKey OrandEncoding.pubKeyToAffine(HexString.hexPrefixAdd(pk)), // address operator @@ -414,16 +414,19 @@ describe('OrandProviderV3', function () { // address ecvrfAddress await orandECVRFV3.getAddress(), // address oracleAddress - await orocleV1.getAddress(), + await orocleV2.getAddress(), // uint256 maxBatchingLimit 100, ]); console.log('Instance:', await instance.getOracle(), await instance.getAddress()); - const upgraded = await upgrades.upgradeProxy(await instance.getAddress(), orand2Factory); + let upgraded = await upgrades.upgradeProxy(await instance.getAddress(), orandV3Factory1); console.log('Upgrade', await upgraded.getOracle(), await upgraded.getAddress()); + + upgraded = await upgrades.upgradeProxy(await instance.getAddress(), orandV3Factory2); + orandProviderV3 = upgraded as any; }); diff --git a/test/009-orocle-v2.spec.ts b/test/009-orocle-v2.spec.ts index d01ea4d..7c2297c 100644 --- a/test/009-orocle-v2.spec.ts +++ b/test/009-orocle-v2.spec.ts @@ -1,18 +1,75 @@ import hre, { upgrades } from 'hardhat'; +import { OrocleV2 } from '../typechain-types'; +import { FixedFloat, OrocleEncoding } from '@orochi-network/utilities'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; -describe('OrocleV1', function () { - it('OrocleV1 should be upgradeable', async () => { +let orocleV2: OrocleV2; +let operator: HardhatEthersSigner; + +describe('OrocleV2', function () { + it('OrocleV2 should be upgradeable', async () => { const { ethers } = hre; - const accounts = await ethers.getSigners(); - const orocleV1Factory = await ethers.getContractFactory('OrocleTest'); - const orocleV2Factory = await ethers.getContractFactory('OrocleV1'); + [operator] = await ethers.getSigners(); + const orocleV2Factory1 = await ethers.getContractFactory('OrocleTest'); + const orocleV2Factory2 = await ethers.getContractFactory('OrocleV2'); - const instance = await upgrades.deployProxy(orocleV1Factory, [accounts[0]]); + const instance = await upgrades.deployProxy(orocleV2Factory1, [[operator.address]]); console.log('Instance:', await instance.owner(), await instance.getAddress()); - const upgraded = await upgrades.upgradeProxy(await instance.getAddress(), orocleV2Factory); + const upgraded = await upgrades.upgradeProxy(await instance.getAddress(), orocleV2Factory2); console.log('Upgrade', await upgraded.owner(), await upgraded.getAddress()); + orocleV2 = upgraded as any; + }); + + it('OrocleV2 should be upgradeable', async () => { + await orocleV2.connect(operator).publishPrice( + OrocleEncoding.encodeTokenPrice([ + { + symbol: 'BTC', + price: 42000n * 10n ** 18n, + }, + ]), + ); + const [round, lastUpdate, price] = await orocleV2.getLatestRound(1, OrocleEncoding.toIdentifier('BTC')); + + console.log('Round data:', { round, lastUpdate, price }); + + console.log( + 'BTC/USDT', + FixedFloat.fromFixedFloat({ + basedValue: BigInt(price), + decimals: 18, + }).pretty('en-us', 2), + ); + }); + + it('OrocleV2 should be upgradeable', async () => { + await orocleV2.connect(operator).publishPrice( + OrocleEncoding.encodeTokenPrice([ + { + symbol: 'BTC', + price: 42000n * 10n ** 18n, + }, + { + symbol: 'ETH', + price: 3821n * 10n ** 18n, + }, + ]), + ); + const [round, lastUpdate, price] = await orocleV2.getLatestRound(1, OrocleEncoding.toIdentifier('ETH')); + + console.log('Round data:', { round, lastUpdate, price }); + + console.log( + 'ETH/USDT', + FixedFloat.fromFixedFloat({ + basedValue: BigInt(price), + decimals: 18, + }).pretty('en-us', 2), + ); + + console.log(await orocleV2.getLatestRound(1, OrocleEncoding.toIdentifier('BTC'))); }); }); diff --git a/test/010-xoro.spec.ts b/test/010-xoro.spec.ts index 7c0874a..f1a46ce 100644 --- a/test/010-xoro.spec.ts +++ b/test/010-xoro.spec.ts @@ -3,6 +3,8 @@ import hre from 'hardhat'; import Deployer from '../helpers/deployer'; import { XORO } from '../typechain-types'; import { expect } from 'chai'; +import { ByteBuffer } from '@orochi-network/utilities'; +import { getBytes } from 'ethers'; const TOKEN_NAME = 'ORC [Beta Token]'; const TOKEN_SYMBOL = 'XORO'; @@ -156,6 +158,36 @@ describe('XORO token', function () { expect(await contract.balanceOf(player03)).eq(115n); }); + it('user can redeem the token with valid digital signature', async () => { + const wallet = hre.ethers.Wallet.createRandom(hre.ethers.provider); + // Send transaction fee + await operator1.sendTransaction({ + to: wallet.address, + value: 10n ** 18n, + }); + // uint96 chainId; + // address beneficiary; + // uint64 nonce; + // uint192 value; + const { chainId } = await hre.ethers.provider.getNetwork(); + const message = ByteBuffer.getInstance() + .writeUint96(chainId) + .writeAddress(wallet.address as `0x${string}`) + .writeUint64(0) + .writeUint192(1000000) + .invoke(); + + const signature = await operator1.signMessage(getBytes(message)); + const combineSignatureAndMessage = ByteBuffer.getInstance() + .writeBytes(signature as `0x${string}`) + .writeBytes(message) + .invoke(); + + await contract.connect(wallet).redeem(combineSignatureAndMessage); + + expect(await contract.balanceOf(wallet.address)).eq(1000000n); + }); + it('Operator can burn token', async () => { await contract.connect(operator1).batchBurn([packData(12n, player01.address)]); expect(await contract.balanceOf(player01)).to.eq(10n);