Skip to content
Merged
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
46 changes: 46 additions & 0 deletions modules/abstract-eth/src/lib/zamaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { confidentialTransferNoProofMethodId, confidentialTransferNoProofTypes }
export const delegateForUserDecryptionTypes = ['address', 'address', 'uint64'] as const;
export const callFromParentTypes = ['address', 'uint256', 'bytes'] as const;
export const aclMulticallTypes = ['bytes[]'] as const;
export const approveTypes = ['address', 'uint256'] as const;

/**
* Function selector for ACL.delegateForUserDecryption(address,address,uint64)
Expand All @@ -37,10 +38,55 @@ export const callFromParentMethodId = addHexPrefix(
EthereumAbi.methodID('callFromParent', [...callFromParentTypes]).toString('hex')
);

/**
* Function selector for ERC-20 approve(address,uint256)
* = keccak256('approve(address,uint256)')[0:4]
* Used for exact-amount (and approve(0) reset) approvals of the ERC-7984 wrapper as spender.
*/
export const approveMethodId = addHexPrefix(EthereumAbi.methodID('approve', [...approveTypes]).toString('hex'));

// ---------------------------------------------------------------------------
// Encoding functions
// ---------------------------------------------------------------------------

/**
* Encodes ERC-20 `approve(spender, amount)` calldata for ERC-7984 shield.
*
* The spender is the confidential wrapper contract; the resulting calldata is
* sent to the underlying ERC-20. Exact-amount approve is used for the shield
* path; `amount = 0` is the USDT-style allowance reset used when
* `requiresApprovalReset` is set on the wrapper pair.
*
* Does not set gasLimit — WP owns gas.
*
* @param wrapperAddress ERC-7984 wrapper contract (spender)
* @param amount Exact allowance to set (base units); `0` for reset
* @returns ABI-encoded calldata hex string (0x-prefixed)
* @throws {Error} if the wrapper address is invalid or amount is negative
*/
export function buildApproveCalldata(wrapperAddress: string, amount: string | number | bigint): string {
let checksummedWrapper: string;
try {
checksummedWrapper = ethers.utils.getAddress(wrapperAddress);
} catch {
throw new Error(`buildApproveCalldata: invalid wrapper address '${wrapperAddress}'`);
}

let amountBn: bigint;
try {
amountBn = typeof amount === 'bigint' ? amount : BigInt(amount);
} catch {
throw new Error(`buildApproveCalldata: invalid amount '${amount}'`);
}
if (amountBn < 0n) {
throw new Error('buildApproveCalldata: amount must be >= 0');
}

const method = EthereumAbi.methodID('approve', [...approveTypes]);
const args = EthereumAbi.rawEncode([...approveTypes], [checksummedWrapper, amountBn.toString()]);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}

/**
* Encodes a single ACL.delegateForUserDecryption() call.
*
Expand Down
96 changes: 94 additions & 2 deletions modules/abstract-eth/test/unit/zamaUtils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import should from 'should';
import EthereumAbi from 'ethereumjs-abi';
import {
buildApproveCalldata,
buildDelegationCalldata,
buildMulticallDelegationCalldata,
buildConfidentialTransferByHandleCalldata,
buildFlushERC7984ForwarderTokenCalldata,
decodeFlushERC7984ForwarderTokenCalldata,
wrapInCallFromParent,
approveMethodId,
delegateForUserDecryptionMethodId,
aclMulticallMethodId,
callFromParentMethodId,
Expand All @@ -20,6 +22,8 @@ describe('Zama Utils', () => {
const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491';
const TOKEN_ADDRESS_3 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF';
// Hoodi hteth:cusdt wrapper (spender for exact-amount approve)
const WRAPPER_ADDRESS = '0x2debbe0487ef921df4457f9e36ed05be2df1ac75';
const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400;

// Helper: decode a delegateForUserDecryption call
Expand All @@ -43,9 +47,97 @@ describe('Zama Utils', () => {
callFromParentMethodId.should.equal('0x77e60b35');
});

it('should have correct selector for approve(address,uint256)', () => {
approveMethodId.should.equal('0x095ea7b3');
});

it('method IDs should all be distinct', () => {
const ids = new Set([delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId]);
ids.size.should.equal(3);
const ids = new Set([
delegateForUserDecryptionMethodId,
aclMulticallMethodId,
callFromParentMethodId,
approveMethodId,
]);
ids.size.should.equal(4);
});
});

// -------------------------------------------------------------------------
describe('buildApproveCalldata', () => {
const EXACT_AMOUNT = '1000000'; // 1 USDT (6 decimals)

describe('output format', () => {
it('should produce a 0x-prefixed hex string', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, EXACT_AMOUNT);
calldata.should.be.a.String();
calldata.should.startWith('0x');
});

it('should have exact length: 4-byte selector + 2 × 32-byte ABI words = 68 bytes (138 chars)', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, EXACT_AMOUNT);
calldata.length.should.equal(138); // '0x' + 136 hex chars
});

it('should start with approve selector 0x095ea7b3', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, EXACT_AMOUNT);
calldata.slice(0, 10).should.equal(approveMethodId);
calldata.slice(0, 10).should.equal('0x095ea7b3');
});
});

describe('ABI parameter encoding', () => {
it('should encode wrapper as spender in the first ABI word', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, EXACT_AMOUNT);
const word1 = calldata.slice(10, 74);
word1.should.equal(WRAPPER_ADDRESS.slice(2).toLowerCase().padStart(64, '0'));
});

it('should encode exact amount in the second ABI word', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, EXACT_AMOUNT);
const word2 = calldata.slice(74, 138);
word2.should.equal(BigInt(EXACT_AMOUNT).toString(16).padStart(64, '0'));
});

it('should encode approve(0) reset when amount is 0', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, 0);
calldata.slice(0, 10).should.equal(approveMethodId);
calldata.slice(74, 138).should.equal('0'.repeat(64));
});

it('should accept amount as bigint', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, 1000000n);
calldata.slice(74, 138).should.equal(BigInt(EXACT_AMOUNT).toString(16).padStart(64, '0'));
});

it('should accept amount as number', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS, 1000000);
calldata.slice(74, 138).should.equal(BigInt(EXACT_AMOUNT).toString(16).padStart(64, '0'));
});
});

describe('validation', () => {
it('should reject an invalid wrapper address', () => {
(() => buildApproveCalldata('not-an-address', EXACT_AMOUNT)).should.throw(/invalid wrapper address/);
});

it('should reject a bad EIP-55 checksum', () => {
// Mixed case that does not match the correct checksum
const badChecksum = '0x2Debbe0487ef921df4457f9e36ed05be2df1ac75';
(() => buildApproveCalldata(badChecksum, EXACT_AMOUNT)).should.throw(/invalid wrapper address/);
});

it('should accept all-lowercase addresses', () => {
const calldata = buildApproveCalldata(WRAPPER_ADDRESS.toLowerCase(), EXACT_AMOUNT);
calldata.slice(0, 10).should.equal(approveMethodId);
});

it('should reject a negative amount', () => {
(() => buildApproveCalldata(WRAPPER_ADDRESS, -1)).should.throw(/amount must be >= 0/);
});

it('should reject a non-numeric amount string', () => {
(() => buildApproveCalldata(WRAPPER_ADDRESS, 'abc')).should.throw(/invalid amount/);
});
});
});

Expand Down
Loading