diff --git a/contracts/mixins/OtpModule.sol b/contracts/mixins/OtpModule.sol new file mode 100644 index 00000000..2ff54c40 --- /dev/null +++ b/contracts/mixins/OtpModule.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title OtpModule + * @notice Abstract contract for OTP (One-Time Password) functionality + * @dev + * - OTPs are pre-generated off-chain using a hash chain: + * k_{0} = keccak256(secret) + * k_{1} = keccak256(k_{0}|user) + * k_{2} = keccak256(k_{1}|user) + * ... + * k_{n} = keccak(k_{n-1}|user) + * - The contract stores the expected hash `keccak256(k_{i+1}|user)` of the next OTP. + * - To authenticate, the user submits k_{i}, and the contract checks: `keccak256(k_{i}|msg.sender) == expected` + * - After successful validation, the expected hash is updated to `keccak256(k_{i}|msg.sender)` + * - Only the last 28 bytes (224 bits) of the hash are stored on-chain to save gas + */ +abstract contract OtpModule { + /// @notice Emitted when the provided OTP code is invalid + error BadOTP(); + /// @notice Emitted when the user has exhausted all available OTP codes + error OtpExhausted(); + /// @notice Emitted when the OTP registration attempt specifies zero allowed codes + error IncorrectOtpAmount(); + + /// @notice Emitted when a user registers or resets their OTP chain + event OTPRegistered(address indexed user, uint32 total); + /// @notice Emitted when a valid OTP code is used by a user + event OTPUsed(address indexed user, uint32 remaining); + + uint256 private constant _MASK_224 = type(uint224).max; + + /// @notice Stores the packed OTP state for each user + /// @dev packed: remaining|expectedHash + // [0..223] bits - expectedHash: last 28 bytes of `keccak256(k_{i}|user)` for next OTP code + // [224-255] bits - remaining: number of remaining OTP codes in 32 bits + mapping(address => uint256) public otp; + + modifier onlyOTP(bytes32 code) { + _validateOtp(code, msg.sender); + _; + } + + function _unpackOTP(uint256 packed) private pure returns (bytes32 expected, uint32 remaining) { + remaining = uint32(packed >> 224); // high 32 bits + expected = bytes32(packed & _MASK_224); // low 224 bits + } + + function _packOTP(bytes32 expected, uint32 remaining) private pure returns (uint256 packed) { + packed = (uint256(remaining) << 224) | (uint256(expected) & _MASK_224); + } + + function _validateOtp(bytes32 code, address user) internal { + (bytes32 expected, uint32 remaining) = _unpackOTP(otp[user]); + if (remaining == 0) revert OtpExhausted(); + if (uint224(uint256(keccak256(abi.encodePacked(code, user)))) != uint224(uint256(expected))) revert BadOTP(); + unchecked { remaining--; } + otp[user] = _packOTP(code, remaining); + emit OTPUsed(user, remaining); + } + + /** + * @notice Registers or resets the OTP chain for the caller + * @dev If an existing chain is active, requires the current OTP code to reset + * @param newCode The last hash code `k_{n}` to be used in new hash chain + * @param total The total number of OTP codes allowed to use, it's `n` in `k_{n}` from newCode param + * @param currentCode The current valid OTP code `k_{i}`, required if the chain is already initialized + */ + function setOTP(bytes32 newCode, uint32 total, bytes32 currentCode) external { + if (total == 0) revert IncorrectOtpAmount(); + (, uint32 remaining) = _unpackOTP(otp[msg.sender]); + if (remaining != 0) { + _validateOtp(currentCode, msg.sender); + } + otp[msg.sender] = _packOTP(newCode, total); + emit OTPRegistered(msg.sender, total); + } +} diff --git a/contracts/tests/mocks/TokenWithOtpModule.sol b/contracts/tests/mocks/TokenWithOtpModule.sol new file mode 100644 index 00000000..57cc5c52 --- /dev/null +++ b/contracts/tests/mocks/TokenWithOtpModule.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { TokenMock } from "../../mocks/TokenMock.sol"; +import { OtpModule } from "../../mixins/OtpModule.sol"; + +contract TokenWithOtpModule is TokenMock, OtpModule { + // solhint-disable-next-line no-empty-blocks + constructor(string memory name, string memory symbol, string memory version) TokenMock(name, symbol) {} + + function transferWithOTP(bytes32 code, address to, uint256 value) external onlyOTP(code) returns (bool) { + _transfer(msg.sender, to, value); + return true; + } +} diff --git a/docs/contracts/libraries/SafeERC20.md b/docs/contracts/libraries/SafeERC20.md index 12b221d1..11cefa8c 100644 --- a/docs/contracts/libraries/SafeERC20.md +++ b/docs/contracts/libraries/SafeERC20.md @@ -12,7 +12,7 @@ Compared to the standard ERC20, this implementation offers several enhancements: - [safeTransferFromUniversal(token, from, to, amount, permit2) internal](#safetransferfromuniversal) - [safeTransferFrom(token, from, to, amount) internal](#safetransferfrom) - [safeTransferFromPermit2(token, from, to, amount) internal](#safetransferfrompermit2) -- [safeTransfer(token, to, value) internal](#safetransfer) +- [safeTransfer(token, to, amount) internal](#safetransfer) - [forceApprove(token, spender, value) internal](#forceapprove) - [safeIncreaseAllowance(token, spender, value) internal](#safeincreaseallowance) - [safeDecreaseAllowance(token, spender, value) internal](#safedecreaseallowance) @@ -123,7 +123,7 @@ the caller to make sure that the higher 96 bits of the `from` and `to` parameter ### safeTransfer ```solidity -function safeTransfer(contract IERC20 token, address to, uint256 value) internal +function safeTransfer(contract IERC20 token, address to, uint256 amount) internal ``` Attempts to safely transfer tokens to another address. @@ -137,7 +137,7 @@ the caller to make sure that the higher 96 bits of the `to` parameter are clean. | ---- | ---- | ----------- | | token | contract IERC20 | The IERC20 token contract from which the tokens will be transferred. | | to | address | The address to which the tokens will be transferred. | -| value | uint256 | The amount of tokens to transfer. | +| amount | uint256 | The amount of tokens to transfer. | ### forceApprove diff --git a/docs/contracts/mixins/OtpModule.md b/docs/contracts/mixins/OtpModule.md new file mode 100644 index 00000000..9907ad68 --- /dev/null +++ b/docs/contracts/mixins/OtpModule.md @@ -0,0 +1,90 @@ + +## OtpModule + +Abstract contract for OTP (One-Time Password) functionality +@dev +- OTPs are pre-generated off-chain using a hash chain: + k_{0} = keccak256(secret) + k_{1} = keccak256(k_{0}|user) + k_{2} = keccak256(k_{1}|user) + ... + k_{n} = keccak(k_{n-1}|user) +- The contract stores the expected hash `keccak256(k_{i+1}|user)` of the next OTP. +- To authenticate, the user submits k_{i}, and the contract checks: `keccak256(k_{i}|msg.sender) == expected` +- After successful validation, the expected hash is updated to `keccak256(k_{i}|msg.sender)` +- Only the last 28 bytes (224 bits) of the hash are stored on-chain to save gas + +### Functions list +- [_validateOtp(code, user) internal](#_validateotp) +- [setOTP(newCode, total, currentCode) external](#setotp) + +### Events list +- [OTPRegistered(user, total) ](#otpregistered) +- [OTPUsed(user, remaining) ](#otpused) + +### Errors list +- [BadOTP() ](#badotp) +- [OtpExhausted() ](#otpexhausted) +- [IncorrectOtpAmount() ](#incorrectotpamount) + +### Functions +### _validateOtp + +```solidity +function _validateOtp(bytes32 code, address user) internal +``` + +### setOTP + +```solidity +function setOTP(bytes32 newCode, uint32 total, bytes32 currentCode) external +``` +Registers or resets the OTP chain for the caller + +_If an existing chain is active, requires the current OTP code to reset_ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| newCode | bytes32 | The last hash code `k_{n}` to be used in new hash chain | +| total | uint32 | The total number of OTP codes allowed to use, it's `n` in `k_{n}` from newCode param | +| currentCode | bytes32 | The current valid OTP code `k_{i}`, required if the chain is already initialized | + +### Events +### OTPRegistered + +```solidity +event OTPRegistered(address user, uint32 total) +``` +Emitted when a user registers or resets their OTP chain + +### OTPUsed + +```solidity +event OTPUsed(address user, uint32 remaining) +``` +Emitted when a valid OTP code is used by a user + +### Errors +### BadOTP + +```solidity +error BadOTP() +``` +Emitted when the provided OTP code is invalid + +### OtpExhausted + +```solidity +error OtpExhausted() +``` +Emitted when the user has exhausted all available OTP codes + +### IncorrectOtpAmount + +```solidity +error IncorrectOtpAmount() +``` +Emitted when the OTP registration attempt specifies zero allowed codes + diff --git a/package.json b/package.json index 246dd14d..8f0c9621 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1inch/solidity-utils", - "version": "6.6.0", + "version": "6.7.0", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "exports": { diff --git a/test/contracts/OtpModule.test.ts b/test/contracts/OtpModule.test.ts new file mode 100644 index 00000000..bfafc707 --- /dev/null +++ b/test/contracts/OtpModule.test.ts @@ -0,0 +1,98 @@ +import { constants } from '../../src/prelude'; +import { expect } from '../../src/expect'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { ethers } from 'hardhat'; +import { trim0x } from '../../src'; + +describe('OtpModule', function () { + function generateOtpChain(secret: string, user: string, length = 20) { + const codes = []; + let k = ethers.keccak256(Buffer.from(trim0x(secret), 'hex')); + const userBuf = Buffer.from(trim0x(user), 'hex'); + + for (let i = 0; i < length; i++) { + codes.push(k); + const kBuf = Buffer.from(trim0x(k), 'hex'); + k = ethers.keccak256(Buffer.concat([kBuf, userBuf])); + } + + return codes; + } + + function decodePackedOtp(packed: bigint) { + const remaining = Number(packed >> 224n); // high 32 bits + const expected = '0x' + (packed & ((1n << 224n) - 1n)).toString(16).padStart(56, '0'); // low 224 bits + return { expected, remaining }; + } + + async function deployTokenWithOtp() { + const [alice, bob, carol] = await ethers.getSigners(); + const TokenWithOtpModule = await ethers.getContractFactory('TokenWithOtpModule'); + const token = await TokenWithOtpModule.deploy('Token', 'TKN', '1'); + + const codes = { + alice: generateOtpChain('ALICE SECRET', alice.address), + bob: generateOtpChain('BOB SECRET', bob.address), + carol: generateOtpChain('CAROL SECRET', carol.address), + }; + await token.mint(bob.address, 1000); + await token.connect(bob).setOTP(codes.bob[19], 19, constants.ZERO_BYTES32); + + return { addrs: { alice, bob, carol }, token, codes }; + } + + describe('setOTP', function () { + it('should set otp correct when it is not set', async function () { + const { addrs: { alice }, token, codes } = await loadFixture(deployTokenWithOtp); + await token.setOTP(codes.alice[19], 19, constants.ZERO_BYTES32); + const { expected, remaining } = decodePackedOtp(await token.otp(alice.address)); + expect(trim0x(expected)).to.be.equal(codes.alice[19].slice(-56)); + expect(remaining).to.be.equal(19); + }); + + it('should reset otp correct', async function () { + const { addrs: { alice }, token, codes } = await loadFixture(deployTokenWithOtp); + await token.setOTP(codes.alice[19], 19, constants.ZERO_BYTES32); + await token.setOTP(codes.alice[10], 10, codes.alice[18]); + const { expected, remaining } = decodePackedOtp(await token.otp(alice.address)); + expect(trim0x(expected)).to.be.equal(codes.alice[10].slice(-56)); + expect(remaining).to.be.equal(10); + }); + + it('should not reset otp with wrong code', async function () { + const { token, codes } = await loadFixture(deployTokenWithOtp); + await token.setOTP(codes.alice[19], 19, constants.ZERO_BYTES32); + await expect(token.setOTP(codes.alice[10], 10, codes.alice[10])) + .to.be.revertedWithCustomError(token, 'BadOTP'); + }); + + it('should not reset otp with total = 0', async function () { + const { token, codes } = await loadFixture(deployTokenWithOtp); + await expect(token.setOTP(codes.alice[19], 0, constants.ZERO_BYTES32)) + .to.be.revertedWithCustomError(token, 'IncorrectOtpAmount'); + }); + }); + + describe('modifier', function () { + it('should transfer with correct otp code', async function () { + const { addrs: { alice, bob }, token, codes } = await loadFixture(deployTokenWithOtp); + const tx = token.connect(bob).transferWithOTP(codes.bob[18], alice, 100); + await expect(tx).to.be.changeTokenBalances(token, [alice, bob], [100, -100]); + }); + + it('should not transfer with incorrect otp code', async function () { + const { addrs: { alice, bob }, token, codes } = await loadFixture(deployTokenWithOtp); + await expect(token.connect(bob).transferWithOTP(codes.bob[17], alice, 100)) + .to.be.revertedWithCustomError(token, 'BadOTP'); + }); + + it('should transfer until otp exhausted', async function () { + const { addrs: { alice, bob }, token, codes } = await loadFixture(deployTokenWithOtp); + for (let i = 18; i >= 0; i--) { + await token.connect(bob).transferWithOTP(codes.bob[i], alice, 10); + } + await expect(token.connect(bob).transferWithOTP(codes.bob[0], alice, 10)) + .to.be.revertedWithCustomError(token, 'OtpExhausted'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index ba1e5956..61a593b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3139,7 +3139,7 @@ electron-to-chromium@^1.5.149: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.150.tgz#3120bf34453a7a82cb4d9335df20680b2bb40649" integrity sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA== -elliptic@6.5.4, elliptic@6.6.1, elliptic@^6.5.5, elliptic@^6.5.7: +elliptic@6.5.4, elliptic@6.6.1, elliptic@^6.5.7: version "6.6.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==