From df1c66b7abe71cb2d54ec0488526fe416982c426 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Sun, 24 May 2026 02:46:47 +0530 Subject: [PATCH] feat(sdk-coin-eth): implement enableToken flow for ERC-7984 confidential tokens TICKET: CHALO-472 --- modules/abstract-eth/src/lib/zamaUtils.ts | 54 +++ modules/sdk-coin-eth/src/erc7984Token.ts | 155 +++++- .../sdk-coin-eth/test/unit/erc7984Token.ts | 456 ++++++++++++++++++ 3 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 modules/sdk-coin-eth/test/unit/erc7984Token.ts diff --git a/modules/abstract-eth/src/lib/zamaUtils.ts b/modules/abstract-eth/src/lib/zamaUtils.ts index 30055d4e56..c3f1361bac 100644 --- a/modules/abstract-eth/src/lib/zamaUtils.ts +++ b/modules/abstract-eth/src/lib/zamaUtils.ts @@ -134,3 +134,57 @@ export function wrapInCallFromParent(targetAddress: string, calldata: string): s ); return addHexPrefix(Buffer.concat([method, args]).toString('hex')); } + +/** + * Decodes token contract addresses from delegation calldata. + * + * Handles two shapes of calldata: + * - Direct ACL.multicall(bytes[]) (root wallet path) + * - ForwarderV4.callFromParent(address, uint256, bytes) wrapping a multicall (forwarder path) + * + * @param calldata ABI-encoded delegation calldata (0x-prefixed or raw hex) + * @returns Array of token contract addresses (lowercase) found in the delegation calls + * @throws {Error} if the calldata does not start with a recognised method selector + */ +export function decodeTokenAddressesFromDelegationCalldata(calldata: string): string[] { + const data = calldata.startsWith('0x') ? calldata : '0x' + calldata; + const methodId = data.slice(0, 10); + const abiCoder = new ethers.utils.AbiCoder(); + + let multicallHex: string; + + if (methodId === callFromParentMethodId) { + // Decode callFromParent(address, uint256, bytes) — inner bytes is the full multicall calldata. + // ethers v5 returns `bytes` as a hex string; use hexlify to normalise to a 0x-prefixed hex string + // regardless of whether the runtime returns a string or a Uint8Array. + const decoded = abiCoder.decode([...callFromParentTypes], '0x' + data.slice(10)); + multicallHex = ethers.utils.hexlify(decoded[2]); + } else if (methodId === aclMulticallMethodId) { + multicallHex = data; + } else { + throw new Error('Not a valid delegation calldata'); + } + + if (multicallHex.slice(0, 10) !== aclMulticallMethodId) { + throw new Error('Not a valid delegation calldata'); + } + + // Decode multicall(bytes[]) — each element is an inner delegateForUserDecryption call. + // ethers v5 returns bytes[] elements as hex strings; use hexlify to normalise each element. + const decoded = abiCoder.decode(['bytes[]'], '0x' + multicallHex.slice(10)); + const innerCalls: unknown[] = decoded[0]; + + const tokenAddresses: string[] = []; + for (const innerCall of innerCalls) { + const innerHex = ethers.utils.hexlify(innerCall as ethers.utils.BytesLike).slice(2); // strip 0x + const innerMethodId = '0x' + innerHex.slice(0, 8); + if (innerMethodId !== delegateForUserDecryptionMethodId) { + continue; + } + // Decode delegateForUserDecryption(address delegate, address tokenAddress, uint64 expiry) + const innerDecoded = abiCoder.decode([...delegateForUserDecryptionTypes], '0x' + innerHex.slice(8)); + tokenAddresses.push((innerDecoded[1] as string).toLowerCase()); + } + + return tokenAddresses; +} diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index d38cf6440b..5a4d8d017b 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -1,10 +1,17 @@ /** * @prettier */ -import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; +import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor, TokenEnablementConfig } from '@bitgo/sdk-core'; -import { coins, Erc7984TokenConfig, tokens } from '@bitgo/statics'; -import { CoinNames, DecryptionDelegationBuilder } from '@bitgo/abstract-eth'; +import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics'; +import { + CoinNames, + DecryptionDelegationBuilder, + decodeTokenAddressesFromDelegationCalldata, + VerifyEthTransactionOptions, + aclMulticallMethodId, + callFromParentMethodId, +} from '@bitgo/abstract-eth'; import { Eth } from './eth'; import { TransactionBuilder } from './lib'; @@ -114,6 +121,148 @@ export class Erc7984Token extends Eth { return new TransactionBuilder(coins.get(this.getBaseChain())); } + /** @inheritDoc */ + getTokenEnablementConfig(): TokenEnablementConfig { + return { + requiresTokenEnablement: true, + supportsMultipleTokenEnablements: true, + }; + } + + /** @inheritDoc */ + async verifyTransaction(params: VerifyEthTransactionOptions): Promise { + if (params.txParams?.type === 'enabletoken') { + return this.verifyEnableTokenTransaction(params); + } + return super.verifyTransaction(params); + } + + /** + * Verifies a token enablement transaction for ERC-7984 decryption delegation. + * + * TSS path: decodes the raw tx and verifies it calls the ACL contract with + * calldata that covers all requested token contract addresses. + * + * Multisig path: verifies the buildParams recipients carry the correct tokenNames + * and zero amounts. + */ + private async verifyEnableTokenTransaction(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild, walletType } = params; + + if (walletType === 'tss') { + // TSS path: full raw-tx decode + const enableTokens = txParams.enableTokens; + if (!enableTokens || enableTokens.length === 0) { + throw new Error('verifyEnableTokenTransaction: enableTokens must be non-empty for TSS path'); + } + if (!txPrebuild.txHex) { + throw new Error('verifyEnableTokenTransaction: missing txHex in txPrebuild'); + } + + // Resolve requested token names → contract addresses + const requestedAddresses = enableTokens.map((t) => { + const tokenCoin = this.bitgo.coin(t.name) as Erc7984Token; + return tokenCoin.tokenContractAddress.toLowerCase(); + }); + + // Parse the raw transaction + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Verify transaction targets the correct contract based on calldata shape + const network = this.getNetwork() as EthereumNetwork; + const aclContractAddress = network?.zamaAclContractAddress; + if (!aclContractAddress) { + throw new Error('verifyEnableTokenTransaction: zamaAclContractAddress not configured for this network'); + } + if (!txJson.to) { + throw new Error('verifyEnableTokenTransaction: transaction is missing recipient address'); + } + + // Inspect calldata method ID to distinguish root wallet from forwarder wallet: + // aclMulticallMethodId → root wallet: to = ACL contract directly + // callFromParentMethodId → forwarder wallet: to = forwarder, ACL address is inside calldata + const calldataMethodId = txJson.data.slice(0, 10); + if (calldataMethodId === aclMulticallMethodId) { + // Root wallet (base address): tx calls the ACL contract directly + if (txJson.to.toLowerCase() !== aclContractAddress.toLowerCase()) { + throw new Error( + `verifyEnableTokenTransaction: transaction target ${txJson.to} does not match ACL contract ${aclContractAddress}` + ); + } + } else if (calldataMethodId === callFromParentMethodId) { + // Forwarder wallet: tx calls the forwarder, which calls the ACL via callFromParent. + // The forwarder address is wallet-specific and cannot be statically verified here; + // token address correctness is still verified below via calldata decoding. + } else { + throw new Error( + `verifyEnableTokenTransaction: unrecognised calldata method ID ${calldataMethodId}; expected multicall or callFromParent` + ); + } + + // Verify value is 0 + if (txJson.value !== '0') { + throw new Error(`verifyEnableTokenTransaction: expected transaction value 0 but got ${txJson.value}`); + } + + // Decode token addresses from calldata and verify all requested tokens are present + const decodedAddresses = decodeTokenAddressesFromDelegationCalldata(txJson.data); + for (const requested of requestedAddresses) { + if (!decodedAddresses.includes(requested)) { + throw new Error( + `verifyEnableTokenTransaction: requested token ${requested} not found in delegation calldata` + ); + } + } + + return true; + } else { + // Multisig path: buildParams-level check + const recipients = txPrebuild.buildParams?.recipients as + | Array<{ tokenName?: string; amount?: string }> + | undefined; + if (!recipients || recipients.length === 0) { + throw new Error('verifyEnableTokenTransaction: missing buildParams.recipients for multisig path'); + } + + // Determine requested token names from txParams + const requestedTokenNames: string[] = []; + if (txParams.enableTokens && txParams.enableTokens.length > 0) { + requestedTokenNames.push(...txParams.enableTokens.map((t) => t.name)); + } else if (txParams.recipients && txParams.recipients.length > 0) { + requestedTokenNames.push(...txParams.recipients.map((r: any) => r.tokenName).filter(Boolean)); + } + + // Verify all recipients have tokenName and amount = '0' + for (const recipient of recipients) { + if (!recipient.tokenName) { + throw new Error('verifyEnableTokenTransaction: recipient is missing tokenName in buildParams'); + } + if (recipient.amount !== '0') { + throw new Error( + `verifyEnableTokenTransaction: expected amount 0 for token enablement but got ${recipient.amount}` + ); + } + } + + // Verify requested token names are present in recipients + if (requestedTokenNames.length > 0) { + const recipientTokenNames = recipients.map((r) => r.tokenName); + for (const requested of requestedTokenNames) { + if (!recipientTokenNames.includes(requested)) { + throw new Error( + `verifyEnableTokenTransaction: requested token ${requested} not found in buildParams recipients` + ); + } + } + } + + return true; + } + } + /** * Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption * delegation transactions. diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts new file mode 100644 index 0000000000..0662fdb0c2 --- /dev/null +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -0,0 +1,456 @@ +/** + * Unit tests for Erc7984Token. + * + * Covers: + * - getTokenEnablementConfig + * - verifyTransaction (TSS and multisig paths) + * - decodeTokenAddressesFromDelegationCalldata (round-trip and forwarder-wrapped) + */ +import should from 'should'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { TransactionType } from '@bitgo/sdk-core'; +import { + buildMulticallDelegationCalldata, + wrapInCallFromParent, + decodeTokenAddressesFromDelegationCalldata, +} from '@bitgo/abstract-eth'; +import { Erc7984Token } from '../../src/erc7984Token'; +import { TransactionBuilder } from '../../src/lib'; +import { getBuilder } from './getBuilder'; +import { register } from '../../src/register'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Hoodi ACL contract address (Networks.test.hoodi.zamaAclContractAddress) +const ZAMA_ACL_ADDRESS = '0x6d3faf6f86e1ff9f3b0831dda920aba1cbd5bd68'; +const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; + +// hteth:ctest1 token contract address (from statics/erc7984Tokens.ts) +const CTEST1_TOKEN_ADDRESS = '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1'; +// hteth:cusdt token contract address (from statics/erc7984Tokens.ts) +const CUSDT_TOKEN_ADDRESS = '0x2debbe0487ef921df4457f9e36ed05be2df1ac75'; + +const WRONG_TOKEN_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const WRONG_ACL_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const FORWARDER_ADDRESS = '0x1234567890123456789012345678901234567890'; + +const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Builds a root-wallet delegation tx hex (to = ACL, data = multicall). + */ +async function buildDelegationTxHex(aclAddress: string, tokenAddresses: string[], value = '0'): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(aclAddress); + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, tokenAddresses, EXPIRY); + txBuilder.data(calldata); + if (value !== '0') { + txBuilder.value(value); + } + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +/** + * Builds a forwarder delegation tx hex (to = forwarder, data = callFromParent(ACL, 0, multicall)). + * This mirrors what DecryptionDelegationBuilder.build() returns when forwarderAddress is set. + */ +async function buildForwarderDelegationTxHex( + forwarderAddress: string, + aclAddress: string, + tokenAddresses: string[] +): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(forwarderAddress); + const innerCalldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, tokenAddresses, EXPIRY); + const outerCalldata = wrapInCallFromParent(aclAddress, innerCalldata); + txBuilder.data(outerCalldata); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Erc7984Token', function () { + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + }); + + // ------------------------------------------------------------------------- + describe('getTokenEnablementConfig', function () { + it('should return requiresTokenEnablement: true', function () { + const config = coin.getTokenEnablementConfig(); + config.requiresTokenEnablement.should.equal(true); + }); + + it('should return supportsMultipleTokenEnablements: true', function () { + const config = coin.getTokenEnablementConfig(); + config.supportsMultipleTokenEnablements.should.equal(true); + }); + }); + + // ------------------------------------------------------------------------- + describe('verifyTransaction – non-enable-token', function () { + it('should fall through to parent when type is not enabletoken', async function () { + // The parent verifyTransaction requires recipients, wallet, etc. When we + // pass a params object with no type, it falls into the parent path and + // throws with the parent's "missing params" error — confirming the + // override only intercepts the enabletoken type. + await coin + .verifyTransaction({ + txParams: { recipients: [] }, + txPrebuild: {} as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/missing params/); + }); + }); + + // ------------------------------------------------------------------------- + describe('verifyTransaction – TSS path', function () { + it('should verify a valid single-token delegation tx', async function () { + const txHex = await buildDelegationTxHex(ZAMA_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS]); + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }); + result.should.equal(true); + }); + + it('should verify a valid multi-token delegation tx', async function () { + const txHex = await buildDelegationTxHex(ZAMA_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS, CUSDT_TOKEN_ADDRESS]); + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }, { name: 'hteth:cusdt' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }); + result.should.equal(true); + }); + + it('should throw when ACL address does not match', async function () { + const txHex = await buildDelegationTxHex(WRONG_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS]); + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/does not match ACL contract/); + }); + + it('should throw when calldata contains wrong token address', async function () { + const txHex = await buildDelegationTxHex(ZAMA_ACL_ADDRESS, [WRONG_TOKEN_ADDRESS]); + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/not found in delegation calldata/); + }); + + it('should verify a valid forwarder delegation tx (callFromParent shape)', async function () { + const txHex = await buildForwarderDelegationTxHex(FORWARDER_ADDRESS, ZAMA_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS]); + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }); + result.should.equal(true); + }); + + it('should verify a forwarder delegation tx with multiple tokens', async function () { + const txHex = await buildForwarderDelegationTxHex(FORWARDER_ADDRESS, ZAMA_ACL_ADDRESS, [ + CTEST1_TOKEN_ADDRESS, + CUSDT_TOKEN_ADDRESS, + ]); + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }, { name: 'hteth:cusdt' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }); + result.should.equal(true); + }); + + it('should throw when forwarder calldata contains wrong token address', async function () { + const txHex = await buildForwarderDelegationTxHex(FORWARDER_ADDRESS, ZAMA_ACL_ADDRESS, [WRONG_TOKEN_ADDRESS]); + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/not found in delegation calldata/); + }); + + it('should throw when calldata has unrecognised method ID', async function () { + // Build a tx with arbitrary calldata that is neither multicall nor callFromParent + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(ZAMA_ACL_ADDRESS); + txBuilder.data('0xdeadbeef'); + const tx = await txBuilder.build(); + const txHex = tx.toBroadcastFormat(); + + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/unrecognised calldata method ID/); + }); + + it('should throw when transaction value is not 0', async function () { + const txHex = await buildDelegationTxHex(ZAMA_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS], '1'); + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/expected transaction value 0/); + }); + + it('should throw when enableTokens is empty', async function () { + const txHex = await buildDelegationTxHex(ZAMA_ACL_ADDRESS, [CTEST1_TOKEN_ADDRESS]); + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [], + }, + txPrebuild: { txHex } as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/enableTokens must be non-empty/); + }); + + it('should throw when txHex is missing', async function () { + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + enableTokens: [{ name: 'hteth:ctest1' }], + }, + txPrebuild: {} as any, + wallet: {} as any, + walletType: 'tss', + }) + .should.be.rejectedWith(/missing txHex/); + }); + }); + + // ------------------------------------------------------------------------- + describe('verifyTransaction – multisig path', function () { + it('should verify valid buildParams recipients', async function () { + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }] as any, + }, + txPrebuild: { + buildParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }], + }, + } as any, + wallet: {} as any, + walletType: 'onchain', + }); + result.should.equal(true); + }); + + it('should verify multiple token recipients', async function () { + const result = await coin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [ + { tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }, + { tokenName: 'hteth:cusdt', address: '0xabc', amount: '0' }, + ] as any, + }, + txPrebuild: { + buildParams: { + type: 'enabletoken', + recipients: [ + { tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }, + { tokenName: 'hteth:cusdt', address: '0xabc', amount: '0' }, + ], + }, + } as any, + wallet: {} as any, + walletType: 'onchain', + }); + result.should.equal(true); + }); + + it('should throw when buildParams recipients have wrong tokenName', async function () { + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }] as any, + }, + txPrebuild: { + buildParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:wrongtoken', address: '0xabc', amount: '0' }], + }, + } as any, + wallet: {} as any, + walletType: 'onchain', + }) + .should.be.rejectedWith(/not found in buildParams recipients/); + }); + + it('should throw when amount is not 0', async function () { + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:ctest1', address: '0xabc', amount: '0' }] as any, + }, + txPrebuild: { + buildParams: { + type: 'enabletoken', + recipients: [{ tokenName: 'hteth:ctest1', address: '0xabc', amount: '100' }], + }, + } as any, + wallet: {} as any, + walletType: 'onchain', + }) + .should.be.rejectedWith(/expected amount 0/); + }); + + it('should throw when recipients is missing', async function () { + await coin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + }, + txPrebuild: { + buildParams: {}, + } as any, + wallet: {} as any, + walletType: 'onchain', + }) + .should.be.rejectedWith(/missing buildParams.recipients/); + }); + }); +}); + +// --------------------------------------------------------------------------- +// decodeTokenAddressesFromDelegationCalldata tests +// --------------------------------------------------------------------------- + +describe('decodeTokenAddressesFromDelegationCalldata', function () { + it('should decode a single-token multicall calldata', function () { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [CTEST1_TOKEN_ADDRESS], EXPIRY); + const addresses = decodeTokenAddressesFromDelegationCalldata(calldata); + addresses.should.deepEqual([CTEST1_TOKEN_ADDRESS.toLowerCase()]); + }); + + it('should decode a multi-token multicall calldata', function () { + const calldata = buildMulticallDelegationCalldata( + DELEGATE_ADDRESS, + [CTEST1_TOKEN_ADDRESS, CUSDT_TOKEN_ADDRESS], + EXPIRY + ); + const addresses = decodeTokenAddressesFromDelegationCalldata(calldata); + addresses.should.deepEqual([CTEST1_TOKEN_ADDRESS.toLowerCase(), CUSDT_TOKEN_ADDRESS.toLowerCase()]); + }); + + it('should return addresses in lowercase', function () { + const mixedCase = '0x7B1D59bbCD291daA59cB6C8C5bc04De1AFC4aBA1'; + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [mixedCase], EXPIRY); + const addresses = decodeTokenAddressesFromDelegationCalldata(calldata); + addresses[0].should.equal(mixedCase.toLowerCase()); + }); + + it('should handle callFromParent-wrapped multicall calldata', function () { + const inner = buildMulticallDelegationCalldata( + DELEGATE_ADDRESS, + [CTEST1_TOKEN_ADDRESS, CUSDT_TOKEN_ADDRESS], + EXPIRY + ); + const wrapped = wrapInCallFromParent(ZAMA_ACL_ADDRESS, inner); + const addresses = decodeTokenAddressesFromDelegationCalldata(wrapped); + addresses.should.deepEqual([CTEST1_TOKEN_ADDRESS.toLowerCase(), CUSDT_TOKEN_ADDRESS.toLowerCase()]); + }); + + it('should work with calldata missing 0x prefix', function () { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [CTEST1_TOKEN_ADDRESS], EXPIRY); + const noPrefix = calldata.slice(2); // strip 0x + const addresses = decodeTokenAddressesFromDelegationCalldata(noPrefix); + addresses.should.deepEqual([CTEST1_TOKEN_ADDRESS.toLowerCase()]); + }); + + it('should throw for unrecognised calldata', function () { + should.throws( + () => decodeTokenAddressesFromDelegationCalldata('0xdeadbeef00000000'), + /Not a valid delegation calldata/ + ); + }); +});