Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions contracts/mixins/OtpModule.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
16 changes: 16 additions & 0 deletions contracts/tests/mocks/TokenWithOtpModule.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 3 additions & 3 deletions docs/contracts/libraries/SafeERC20.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
90 changes: 90 additions & 0 deletions docs/contracts/mixins/OtpModule.md
Original file line number Diff line number Diff line change
@@ -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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
98 changes: 98 additions & 0 deletions test/contracts/OtpModule.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==

[email protected], [email protected], elliptic@^6.5.5, elliptic@^6.5.7:
[email protected], [email protected], 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==
Expand Down
Loading