From ac5410606336e95063a0221784ce8bb8241b5d48 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Wed, 13 May 2026 16:46:51 +0530 Subject: [PATCH] feat: add decryption delegation support for zama TICKET: CHALO-434 --- .../src/lib/decryptionDelegationBuilder.ts | 124 +++++++ modules/abstract-eth/src/lib/index.ts | 2 + .../src/lib/transactionBuilder.ts | 9 +- modules/abstract-eth/src/lib/utils.ts | 8 + modules/abstract-eth/src/lib/zamaUtils.ts | 129 ++++++++ .../test/unit/decryptionDelegationBuilder.ts | 296 +++++++++++++++++ modules/abstract-eth/test/unit/index.ts | 2 + .../decryptionDelegation.ts | 192 +++++++++++ .../test/unit/transactionBuilder/index.ts | 1 + modules/abstract-eth/test/unit/zamaUtils.ts | 305 ++++++++++++++++++ modules/sdk-coin-eth/src/erc7984Token.ts | 51 ++- .../decryptionDelegation.ts | 65 ++++ .../decryptionDelegationTxBuilder.ts | 169 ++++++++++ .../sdk-core/src/account-lib/baseCoin/enum.ts | 2 + 14 files changed, 1341 insertions(+), 14 deletions(-) create mode 100644 modules/abstract-eth/src/lib/decryptionDelegationBuilder.ts create mode 100644 modules/abstract-eth/src/lib/zamaUtils.ts create mode 100644 modules/abstract-eth/test/unit/decryptionDelegationBuilder.ts create mode 100644 modules/abstract-eth/test/unit/transactionBuilder/decryptionDelegation.ts create mode 100644 modules/abstract-eth/test/unit/zamaUtils.ts create mode 100644 modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegation.ts create mode 100644 modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegationTxBuilder.ts diff --git a/modules/abstract-eth/src/lib/decryptionDelegationBuilder.ts b/modules/abstract-eth/src/lib/decryptionDelegationBuilder.ts new file mode 100644 index 0000000000..b71dd13fe3 --- /dev/null +++ b/modules/abstract-eth/src/lib/decryptionDelegationBuilder.ts @@ -0,0 +1,124 @@ +import { buildMulticallDelegationCalldata, wrapInCallFromParent } from './zamaUtils'; + +/** + * Parameters for building a Zama ERC-7984 decryption delegation transaction. + */ +export interface DecryptionDelegationBuilderParams { + /** Address of the Zama ACL contract on the target network. */ + aclContractAddress: string; + + /** + * BitGo enterprise viewing key address that receives decryption rights. + */ + delegateAddress: string; + + /** + * ERC-7984 token contract addresses to delegate for. + * One or more addresses — always encoded as ACL.multicall([delegateForUserDecryption x N]). + * Pass a single address for single-token delegation; the multicall wrapper is always used + * for a consistent transaction structure regardless of token count. + */ + tokenContractAddresses: string[]; + + /** + * Delegation expiry as a Unix timestamp (seconds). + * Recommended: Math.floor(Date.now() / 1000) + 365 * 86400 (1 year) + */ + expiryTimestamp: number; + + /** + * Optional forwarder contract address. + * + * When set, the delegation calldata is wrapped in a + * ForwarderV4.callFromParent(aclContractAddress, 0, delegationCalldata) call, + * so that the forwarder itself becomes msg.sender (and therefore the delegator) + * in the ACL call. + * + * Only the parentAddress (root wallet) may call callFromParent — + * this is enforced by the forwarder's onlyParent modifier. + * + * Leave undefined when the root wallet is delegating directly. + */ + forwarderAddress?: string; +} + +/** + * The wallet-type-agnostic output of DecryptionDelegationBuilder.build(). + * + * WP is responsible for routing this to the correct signing path: + * - MPC (TSS): submit as a raw transaction {to, data, value=0} + * - Multisig root: sendMultiSig(walletContract, to, 0, data, expiry, seqId, sig) + * - Multisig forwarder: sendMultiSig(walletContract, forwarder, 0, callFromParentData, expiry, seqId, sig) + */ +export interface DecryptionDelegationTxRequest { + /** + * Transaction recipient: + * - ACL contract address when delegating from root wallet directly + * - Forwarder address when wrapping in callFromParent + */ + to: string; + + /** ABI-encoded calldata for the decryption delegation operation. */ + data: string; + + /** Always '0' — decryption delegation transactions carry no ETH value. */ + value: string; +} + +/** + * Builder for Zama ERC-7984 ACL decryption delegation transactions. + * + * Grants BitGo's enterprise viewing key the right to decrypt ERC-7984 token + * balances on behalf of the wallet owner via ACL.delegateForUserDecryption(). + * + * Produces a DecryptionDelegationTxRequest that works for both MPC and multisig + * wallets. Always uses ACL.multicall() regardless of token count, giving WP a + * consistent transaction structure to handle. + * + * Two scenarios: + * 1. Root wallet → ACL.multicall([delegateForUserDecryption x N]) sent directly to ACL + * 2. Forwarder → callFromParent(ACL, 0, multicall([...])) sent to forwarder contract + * + * Usage: + * const req = new DecryptionDelegationBuilder().build({ + * aclContractAddress: '0xf0Ff...', + * delegateAddress: enterpriseViewingKey, + * tokenContractAddresses: [tokenAddress], // one or more tokens + * expiryTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400, + * }); + */ +export class DecryptionDelegationBuilder { + /** + * Build the decryption delegation transaction request. + * + * @param params Decryption delegation parameters + * @returns DecryptionDelegationTxRequest containing {to, data, value} ready for WP signing + * @throws Error if tokenContractAddresses is empty + */ + build(params: DecryptionDelegationBuilderParams): DecryptionDelegationTxRequest { + const { aclContractAddress, delegateAddress, tokenContractAddresses, expiryTimestamp, forwarderAddress } = params; + + if (tokenContractAddresses.length === 0) { + throw new Error('DecryptionDelegationBuilder: tokenContractAddresses must not be empty'); + } + + // Always encode as ACL.multicall([delegateForUserDecryption x N]) for a consistent + // transaction structure regardless of whether one or many tokens are delegated. + const innerCalldata = buildMulticallDelegationCalldata(delegateAddress, tokenContractAddresses, expiryTimestamp); + + // Optionally wrap in callFromParent for forwarder delegation + if (forwarderAddress !== undefined) { + return { + to: forwarderAddress, + data: wrapInCallFromParent(aclContractAddress, innerCalldata), + value: '0', + }; + } + + return { + to: aclContractAddress, + data: innerCalldata, + value: '0', + }; + } +} diff --git a/modules/abstract-eth/src/lib/index.ts b/modules/abstract-eth/src/lib/index.ts index 86eb9024e5..0eca42a295 100644 --- a/modules/abstract-eth/src/lib/index.ts +++ b/modules/abstract-eth/src/lib/index.ts @@ -1,4 +1,6 @@ export * from './constants'; +export * from './zamaUtils'; +export * from './decryptionDelegationBuilder'; export * from './contractCall'; export * from './iface'; export * from './keyPair'; diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 0fa6380f95..4719d2581a 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -157,6 +157,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.SingleSigSend: return this.buildBase('0x'); case TransactionType.ContractCall: + case TransactionType.DecryptionDelegation: return this.buildGenericContractCallTransaction(); default: throw new BuildTransactionError('Unsupported transaction type'); @@ -295,6 +296,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.setContract(transactionJson.to); break; case TransactionType.ContractCall: + case TransactionType.DecryptionDelegation: this.setContract(transactionJson.to); this.data(transactionJson.data); break; @@ -444,6 +446,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.StakingWithdraw: break; case TransactionType.ContractCall: + case TransactionType.DecryptionDelegation: this.validateContractAddress(); this.validateDataField(); break; @@ -863,7 +866,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { // region generic contract call data(encodedCall: string): void { - const supportedTransactionTypes = [TransactionType.ContractCall, TransactionType.RecoveryWalletDeployment]; + const supportedTransactionTypes = [ + TransactionType.ContractCall, + TransactionType.RecoveryWalletDeployment, + TransactionType.DecryptionDelegation, + ]; if (!supportedTransactionTypes.includes(this._type)) { throw new BuildTransactionError('data can only be set for contract call transaction types'); } diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 05dcdadc51..31ab5bc38a 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -85,6 +85,7 @@ import { sendMultiSigTypesFirstSigner, } from './walletUtil'; import { EthTransactionData } from './types'; +import { delegateForUserDecryptionMethodId } from './zamaUtils'; /** * @param network @@ -727,6 +728,13 @@ const transactionTypesMap = { [UnvoteMethodId]: TransactionType.StakingUnvote, [UnlockMethodId]: TransactionType.StakingUnlock, [WithdrawMethodId]: TransactionType.StakingWithdraw, + // aclMulticallMethodId (multicall(bytes[])) is intentionally NOT mapped here. + // classifyTransaction() only sees calldata, not `to`, so 0xac9650d8 would mislabel + // any OpenZeppelin MulticallUpgradeable call (routers, aggregators, unrelated contracts) + // as DecryptionDelegation. Builder output (which always uses multicall) therefore + // classifies as ContractCall; callers should set TransactionType.DecryptionDelegation + // explicitly when building from a known delegation template. + [delegateForUserDecryptionMethodId]: TransactionType.DecryptionDelegation, }; /** diff --git a/modules/abstract-eth/src/lib/zamaUtils.ts b/modules/abstract-eth/src/lib/zamaUtils.ts new file mode 100644 index 0000000000..40deec0a58 --- /dev/null +++ b/modules/abstract-eth/src/lib/zamaUtils.ts @@ -0,0 +1,129 @@ +import { addHexPrefix, toBuffer } from 'ethereumjs-util'; +import EthereumAbi from 'ethereumjs-abi'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// ABI parameter type arrays +export const delegateForUserDecryptionTypes = ['address', 'address', 'uint64'] as const; +export const callFromParentTypes = ['address', 'uint256', 'bytes'] as const; +export const aclMulticallTypes = ['bytes[]'] as const; + +/** + * Function selector for ACL.delegateForUserDecryption(address,address,uint64) + * = keccak256('delegateForUserDecryption(address,address,uint64)')[0:4] + */ +export const delegateForUserDecryptionMethodId = addHexPrefix( + EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]).toString('hex') +); + +/** + * Function selector for ACL.multicall(bytes[]) + * = keccak256('multicall(bytes[])')[0:4] + * ACL inherits OpenZeppelin MulticallUpgradeable — preserves msg.sender via delegatecall. + */ +export const aclMulticallMethodId = addHexPrefix( + EthereumAbi.methodID('multicall', [...aclMulticallTypes]).toString('hex') +); + +/** + * Function selector for ForwarderV4.callFromParent(address,uint256,bytes) + * = keccak256('callFromParent(address,uint256,bytes)')[0:4] + */ +export const callFromParentMethodId = addHexPrefix( + EthereumAbi.methodID('callFromParent', [...callFromParentTypes]).toString('hex') +); + +// --------------------------------------------------------------------------- +// Encoding functions +// --------------------------------------------------------------------------- + +/** + * Encodes a single ACL.delegateForUserDecryption() call. + * + * Grants `delegateAddress` the right to decrypt ERC-7984 token balances on + * behalf of the calling address (msg.sender) for the specified token contract. + * + * @param delegateAddress BitGo enterprise viewing key address + * @param tokenContractAddress ERC-7984 token contract address + * @param expiryTimestamp Unix seconds; recommended: Math.floor(Date.now()/1000) + 365*86400 + * @returns ABI-encoded calldata hex string (0x-prefixed) + */ +export function buildDelegationCalldata( + delegateAddress: string, + tokenContractAddress: string, + expiryTimestamp: number +): string { + const method = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]); + const args = EthereumAbi.rawEncode( + [...delegateForUserDecryptionTypes], + [delegateAddress, tokenContractAddress, expiryTimestamp] + ); + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); +} + +/** + * Encodes N delegateForUserDecryption calls batched inside ACL.multicall(). + * + * Produces a single TX that grants delegation for all specified token contracts. + * Requires tokenContractAddresses.length >= 1. + * Note: DecryptionDelegationBuilder always uses this function (even for a single token) + * to keep the transaction shape consistent regardless of token count. + * + * @param delegateAddress BitGo enterprise viewing key address + * @param tokenContractAddresses Array of ERC-7984 token contract addresses + * @param expiryTimestamp Unix seconds + * @returns ABI-encoded calldata hex string (0x-prefixed) + */ +export function buildMulticallDelegationCalldata( + delegateAddress: string, + tokenContractAddresses: string[], + expiryTimestamp: number +): string { + if (tokenContractAddresses.length === 0) { + throw new Error('buildMulticallDelegationCalldata: tokenContractAddresses must not be empty'); + } + + // Build each inner delegateForUserDecryption call as raw bytes + const innerCalls: Buffer[] = tokenContractAddresses.map((tokenAddress) => { + const innerMethod = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]); + const innerArgs = EthereumAbi.rawEncode( + [...delegateForUserDecryptionTypes], + [delegateAddress, tokenAddress, expiryTimestamp] + ); + return Buffer.concat([innerMethod, innerArgs]); + }); + + // Encode outer multicall(bytes[]) + const outerMethod = EthereumAbi.methodID('multicall', [...aclMulticallTypes]); + const outerArgs = EthereumAbi.rawEncode([...aclMulticallTypes], [innerCalls]); + return addHexPrefix(Buffer.concat([outerMethod, outerArgs]).toString('hex')); +} + +/** + * Wraps calldata in a ForwarderV4.callFromParent(target, 0, data) call. + * + * Used when a forwarder contract must be msg.sender for an external contract + * call — for example, when the forwarder itself needs to call + * ACL.delegateForUserDecryption() so that its own balance can be decrypted. + * + * Only the parentAddress (root wallet) is allowed to call callFromParent + * (enforced by the forwarder's onlyParent modifier). + * + * @param targetAddress Address of the contract the forwarder will call (e.g. ACL) + * @param calldata ABI-encoded inner calldata (e.g. from buildDelegationCalldata) + * @returns ABI-encoded callFromParent calldata hex string (0x-prefixed) + */ +export function wrapInCallFromParent(targetAddress: string, calldata: string): string { + const method = EthereumAbi.methodID('callFromParent', [...callFromParentTypes]); + const args = EthereumAbi.rawEncode( + [...callFromParentTypes], + [ + targetAddress, + 0, // value: no ETH transfer + toBuffer(calldata), // inner calldata as bytes + ] + ); + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); +} diff --git a/modules/abstract-eth/test/unit/decryptionDelegationBuilder.ts b/modules/abstract-eth/test/unit/decryptionDelegationBuilder.ts new file mode 100644 index 0000000000..013794f791 --- /dev/null +++ b/modules/abstract-eth/test/unit/decryptionDelegationBuilder.ts @@ -0,0 +1,296 @@ +import should from 'should'; +import { DecryptionDelegationBuilder } from '../../src/lib/decryptionDelegationBuilder'; +import EthereumAbi from 'ethereumjs-abi'; +import { buildDelegationCalldata, aclMulticallMethodId, callFromParentMethodId } from '../../src/lib/zamaUtils'; + +describe('DecryptionDelegationBuilder', () => { + const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; + const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; + const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; + const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491'; + const TOKEN_ADDRESS_3 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; + const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + + let builder: DecryptionDelegationBuilder; + + beforeEach(() => { + builder = new DecryptionDelegationBuilder(); + }); + + // ------------------------------------------------------------------------- + describe('Scenario 1: root wallet (multicall to ACL)', () => { + it('should set to=ACL and selector=multicall for a single token', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + + req.to.should.equal(ACL_ADDRESS); + req.data.slice(0, 10).should.equal(aclMulticallMethodId); + req.value.should.equal('0'); + }); + + it('should set to=ACL and selector=multicall for multiple tokens', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2], + expiryTimestamp: EXPIRY, + }); + + req.to.should.equal(ACL_ADDRESS); + req.data.slice(0, 10).should.equal(aclMulticallMethodId); + req.value.should.equal('0'); + }); + + it('single-token build should embed the correct decryption delegation inner call', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + + const expectedInner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY).slice(2); + req.data.should.containEql(expectedInner); + }); + + it('multi-token build should embed inner calls for all tokens', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3], + expiryTimestamp: EXPIRY, + }); + + const tokens = [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3]; + for (const token of tokens) { + const expectedInner = buildDelegationCalldata(DELEGATE_ADDRESS, token, EXPIRY).slice(2); + req.data.should.containEql(expectedInner); + } + }); + + it('inner calls should not contain other tokens not in the list', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + + const unexpectedInner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_2, EXPIRY).slice(2); + req.data.should.not.containEql(unexpectedInner); + }); + + it('should produce longer calldata for more tokens', () => { + const single = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + const triple = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3], + expiryTimestamp: EXPIRY, + }); + triple.data.length.should.be.greaterThan(single.data.length); + }); + }); + + // ------------------------------------------------------------------------- + describe('Scenario 2: forwarder (callFromParent wrapping multicall)', () => { + it('should set to=forwarder and selector=callFromParent for a single token', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + + req.to.should.equal(FORWARDER_ADDRESS); + req.data.slice(0, 10).should.equal(callFromParentMethodId); + req.value.should.equal('0'); + }); + + it('should set to=forwarder and selector=callFromParent for multiple tokens', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + + req.to.should.equal(FORWARDER_ADDRESS); + req.data.slice(0, 10).should.equal(callFromParentMethodId); + req.value.should.equal('0'); + }); + + it('should encode ACL address as the callFromParent target', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + + const payload = Buffer.from(req.data.slice(10), 'hex'); + const [target] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + (target as Buffer).toString('hex').should.equal(ACL_ADDRESS.slice(2).toLowerCase()); + }); + + it('should encode value=0 inside callFromParent', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + + const payload = Buffer.from(req.data.slice(10), 'hex'); + const [, value] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + value.toString().should.equal('0'); + }); + + it('inner data (decoded from callFromParent) should be a valid multicall containing all tokens', () => { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + + const outerPayload = Buffer.from(req.data.slice(10), 'hex'); + const [, , innerData] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], outerPayload); + const innerHex = '0x' + (innerData as Buffer).toString('hex'); + + innerHex.slice(0, 10).should.equal(aclMulticallMethodId); + + const inner1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY).slice(2); + const inner2 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_2, EXPIRY).slice(2); + innerHex.should.containEql(inner1); + innerHex.should.containEql(inner2); + }); + }); + + // ------------------------------------------------------------------------- + describe('parameter isolation', () => { + it('changing ACL address should change the to field (root wallet path)', () => { + const ACL_2 = '0x2222222222222222222222222222222222222222'; + const r1 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + const r2 = builder.build({ + aclContractAddress: ACL_2, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + r1.to.should.equal(ACL_ADDRESS); + r2.to.should.equal(ACL_2); + }); + + it('changing forwarder address should change the to field', () => { + const FWD_2 = '0x3333333333333333333333333333333333333333'; + const r1 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + forwarderAddress: FORWARDER_ADDRESS, + }); + const r2 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + forwarderAddress: FWD_2, + }); + r1.to.should.equal(FORWARDER_ADDRESS); + r2.to.should.equal(FWD_2); + }); + + it('changing delegate address should change the calldata', () => { + const DELEGATE_2 = '0x2222222222222222222222222222222222222222'; + const r1 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + const r2 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_2, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + r1.data.should.not.equal(r2.data); + }); + + it('changing expiry should change the calldata', () => { + const r1 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY, + }); + const r2 = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [TOKEN_ADDRESS], + expiryTimestamp: EXPIRY + 86400, + }); + r1.data.should.not.equal(r2.data); + }); + }); + + // ------------------------------------------------------------------------- + describe('value field', () => { + it('should always be "0" for all scenarios', () => { + const scenarios = [ + { tokenContractAddresses: [TOKEN_ADDRESS] }, + { tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2] }, + { tokenContractAddresses: [TOKEN_ADDRESS], forwarderAddress: FORWARDER_ADDRESS }, + { tokenContractAddresses: [TOKEN_ADDRESS, TOKEN_ADDRESS_2], forwarderAddress: FORWARDER_ADDRESS }, + ]; + + for (const extra of scenarios) { + const req = builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + expiryTimestamp: EXPIRY, + ...extra, + }); + req.value.should.equal('0'); + } + }); + }); + + // ------------------------------------------------------------------------- + describe('error handling', () => { + it('should throw when tokenContractAddresses is empty', () => { + should.throws( + () => + builder.build({ + aclContractAddress: ACL_ADDRESS, + delegateAddress: DELEGATE_ADDRESS, + tokenContractAddresses: [], + expiryTimestamp: EXPIRY, + }), + /DecryptionDelegationBuilder: tokenContractAddresses must not be empty/ + ); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/index.ts b/modules/abstract-eth/test/unit/index.ts index 5018763948..bd8b8e53b9 100644 --- a/modules/abstract-eth/test/unit/index.ts +++ b/modules/abstract-eth/test/unit/index.ts @@ -3,3 +3,5 @@ export * from './token'; export * from './transaction'; export * from './coin'; export * from './messages'; +export * from './zamaUtils'; +export * from './decryptionDelegationBuilder'; diff --git a/modules/abstract-eth/test/unit/transactionBuilder/decryptionDelegation.ts b/modules/abstract-eth/test/unit/transactionBuilder/decryptionDelegation.ts new file mode 100644 index 0000000000..7e133cfecc --- /dev/null +++ b/modules/abstract-eth/test/unit/transactionBuilder/decryptionDelegation.ts @@ -0,0 +1,192 @@ +/** + * TransactionBuilder build and rebuild tests for DecryptionDelegation transaction type. + * + * Verifies three delegation flows end-to-end through build → serialize → deserialize: + * Flow 1 — root wallet multicall (outer selector 0xac9650d8 → ContractCall) + * Flow 2 — explicit DecryptionDelegation type set by caller + * Flow 3 — forwarder callFromParent (outer selector 0x77e60b35 → ContractCall) + */ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { TransactionBuilder } from '../../../src'; +import { + buildMulticallDelegationCalldata, + wrapInCallFromParent, + aclMulticallMethodId, + callFromParentMethodId, +} from '../../../src/lib/zamaUtils'; +import { classifyTransaction } from '../../../src/lib/utils'; + +const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; +const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491'; +const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; +const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + +export function runDecryptionDelegationTests(coinName: string, getBuilder: (coin: string) => TransactionBuilder): void { + describe(`${coinName} transaction builder — DecryptionDelegation flows`, () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder(coinName); + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + // ------------------------------------------------------------------------- + // classifyTransaction — verify selector → type mapping + // ------------------------------------------------------------------------- + describe('classifyTransaction', () => { + it('multicall selector (0xac9650d8) should classify as ContractCall', () => { + const result = classifyTransaction(aclMulticallMethodId + '00'.repeat(28)); + should.equal(result, TransactionType.ContractCall); + }); + + it('callFromParent selector (0x77e60b35) should classify as ContractCall', () => { + const result = classifyTransaction(callFromParentMethodId + '00'.repeat(28)); + should.equal(result, TransactionType.ContractCall); + }); + + it('delegateForUserDecryption selector (0x04f61a95) should classify as DecryptionDelegation', () => { + const result = classifyTransaction('0x04f61a95' + '00'.repeat(28)); + should.equal(result, TransactionType.DecryptionDelegation); + }); + + it('unknown selector should classify as ContractCall (fallback)', () => { + const result = classifyTransaction('0xdeadbeef' + '00'.repeat(28)); + should.equal(result, TransactionType.ContractCall); + }); + }); + + // ------------------------------------------------------------------------- + // Flow 1 — root wallet multicall (outer selector = multicall → ContractCall) + // ------------------------------------------------------------------------- + describe('Flow 1: root wallet multicall delegation (ContractCall type)', () => { + it('should build a tx with multicall delegation data', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(ACL_ADDRESS); + json.data.should.startWith(aclMulticallMethodId); + json.value.should.equal('0'); + }); + + it('should serialize and deserialize correctly (rebuild from hex)', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + }); + + // ------------------------------------------------------------------------- + // Flow 2 — explicit DecryptionDelegation type (WP sets this for TSS path) + // ------------------------------------------------------------------------- + describe('Flow 2: explicit DecryptionDelegation type', () => { + it('should build a DecryptionDelegation tx', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.DecryptionDelegation); + json.to.should.equal(ACL_ADDRESS); + json.data.should.startWith(aclMulticallMethodId); + }); + + it('should serialize and deserialize correctly (rebuild from hex)', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + // from() classifies via outer selector (multicall → ContractCall) and rebuilds correctly + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + }); + + // ------------------------------------------------------------------------- + // Flow 3 — forwarder callFromParent (outer selector = callFromParent → ContractCall) + // ------------------------------------------------------------------------- + describe('Flow 3: forwarder callFromParent delegation (ContractCall type)', () => { + it('should build a tx targeting the forwarder with callFromParent data', async () => { + const innerCalldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const outerCalldata = wrapInCallFromParent(ACL_ADDRESS, innerCalldata); + + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.data(outerCalldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + // outer tx targets the forwarder, not the ACL + json.to.should.equal(FORWARDER_ADDRESS); + json.data.should.startWith(callFromParentMethodId); + }); + + it('should serialize and deserialize correctly (rebuild from hex)', async () => { + const innerCalldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const outerCalldata = wrapInCallFromParent(ACL_ADDRESS, innerCalldata); + + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.data(outerCalldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + }); + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + describe('validation', () => { + it('should throw if contract address is missing for DecryptionDelegation', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.data(calldata); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing contract address'); + }); + + it('should throw if data is missing for DecryptionDelegation', async () => { + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing contract call data field'); + }); + }); + }); +} diff --git a/modules/abstract-eth/test/unit/transactionBuilder/index.ts b/modules/abstract-eth/test/unit/transactionBuilder/index.ts index 2fe6808f7c..64973bde89 100644 --- a/modules/abstract-eth/test/unit/transactionBuilder/index.ts +++ b/modules/abstract-eth/test/unit/transactionBuilder/index.ts @@ -2,3 +2,4 @@ export * from './addressInitialization'; export * from './send'; export * from './walletInitialization'; export * from './flushNft'; +export * from './decryptionDelegation'; diff --git a/modules/abstract-eth/test/unit/zamaUtils.ts b/modules/abstract-eth/test/unit/zamaUtils.ts new file mode 100644 index 0000000000..18a608ad46 --- /dev/null +++ b/modules/abstract-eth/test/unit/zamaUtils.ts @@ -0,0 +1,305 @@ +import should from 'should'; +import EthereumAbi from 'ethereumjs-abi'; +import { + buildDelegationCalldata, + buildMulticallDelegationCalldata, + wrapInCallFromParent, + delegateForUserDecryptionMethodId, + aclMulticallMethodId, + callFromParentMethodId, +} from '../../src/lib/zamaUtils'; + +describe('Zama Utils', () => { + const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; + const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; + const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; + const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491'; + const TOKEN_ADDRESS_3 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; + const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + + // Helper: decode a delegateForUserDecryption call + function decodeDelegationCall(calldata: string): [string, string, number] { + const payload = Buffer.from(calldata.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'address', 'uint64'], payload); + return [(decoded[0] as Buffer).toString('hex'), (decoded[1] as Buffer).toString('hex'), Number(decoded[2])]; + } + + // ------------------------------------------------------------------------- + describe('Method IDs', () => { + it('should have correct selector for delegateForUserDecryption(address,address,uint64)', () => { + delegateForUserDecryptionMethodId.should.equal('0x04f61a95'); + }); + + it('should have correct selector for multicall(bytes[])', () => { + aclMulticallMethodId.should.equal('0xac9650d8'); + }); + + it('should have correct selector for callFromParent(address,uint256,bytes)', () => { + callFromParentMethodId.should.equal('0x77e60b35'); + }); + + it('method IDs should all be distinct', () => { + const ids = new Set([delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId]); + ids.size.should.equal(3); + }); + }); + + // ------------------------------------------------------------------------- + describe('buildDelegationCalldata', () => { + describe('output format', () => { + it('should produce a 0x-prefixed hex string', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + calldata.should.be.a.String(); + calldata.should.startWith('0x'); + }); + + it('should have exact length: 4-byte selector + 3 × 32-byte ABI words = 100 bytes (202 chars)', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + calldata.length.should.equal(202); // '0x' + 200 hex chars + }); + + it('should start with delegateForUserDecryption selector', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + calldata.slice(0, 10).should.equal(delegateForUserDecryptionMethodId); + }); + }); + + describe('ABI parameter encoding — position and value', () => { + it('should encode delegate address as first ABI word (bytes 4–35)', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + // First 32-byte word after 4-byte selector = hex chars 10–74 + const word1 = calldata.slice(10, 74); + word1.should.equal(DELEGATE_ADDRESS.slice(2).toLowerCase().padStart(64, '0')); + }); + + it('should encode token address as second ABI word (bytes 36–67)', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const word2 = calldata.slice(74, 138); + word2.should.equal(TOKEN_ADDRESS.slice(2).toLowerCase().padStart(64, '0')); + }); + + it('should encode expiry timestamp as third ABI word (bytes 68–99)', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const word3 = calldata.slice(138, 202); + const encodedExpiry = EXPIRY.toString(16).padStart(64, '0'); + word3.should.equal(encodedExpiry); + }); + + it('should round-trip decode to original parameters', () => { + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const [delegate, token, expiry] = decodeDelegationCall(calldata); + delegate.should.equal(DELEGATE_ADDRESS.slice(2).toLowerCase()); + token.should.equal(TOKEN_ADDRESS.slice(2).toLowerCase()); + expiry.should.equal(EXPIRY); + }); + }); + + describe('parameter isolation', () => { + it('different delegate addresses should produce different calldata', () => { + const DELEGATE_2 = '0x2222222222222222222222222222222222222222'; + const c1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const c2 = buildDelegationCalldata(DELEGATE_2, TOKEN_ADDRESS, EXPIRY); + c1.should.not.equal(c2); + // Only first word differs + c1.slice(10, 74).should.not.equal(c2.slice(10, 74)); + c1.slice(74).should.equal(c2.slice(74)); // token and expiry unchanged + }); + + it('different token addresses should produce different calldata', () => { + const c1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const c2 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_2, EXPIRY); + c1.should.not.equal(c2); + // Only second word differs + c1.slice(10, 74).should.equal(c2.slice(10, 74)); // delegate unchanged + c1.slice(74, 138).should.not.equal(c2.slice(74, 138)); + c1.slice(138).should.equal(c2.slice(138)); // expiry unchanged + }); + + it('different expiry timestamps should produce different calldata', () => { + const c1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const c2 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY + 86400); + c1.should.not.equal(c2); + // Only third word differs + c1.slice(10, 138).should.equal(c2.slice(10, 138)); // delegate + token unchanged + c1.slice(138).should.not.equal(c2.slice(138)); + }); + }); + + describe('address case handling', () => { + it('should normalise checksummed (mixed-case) addresses to lowercase in encoding', () => { + // TOKEN_ADDRESS_3 is EIP-55 checksummed: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + const calldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_3, EXPIRY); + const word2 = calldata.slice(74, 138); + word2.should.equal(TOKEN_ADDRESS_3.slice(2).toLowerCase().padStart(64, '0')); + }); + }); + }); + + // ------------------------------------------------------------------------- + describe('buildMulticallDelegationCalldata', () => { + describe('output format', () => { + it('should produce a 0x-prefixed hex string', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + calldata.should.be.a.String(); + calldata.should.startWith('0x'); + }); + + it('should start with multicall selector', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + calldata.slice(0, 10).should.equal(aclMulticallMethodId); + }); + }); + + describe('inner call correctness', () => { + // Strategy: build expected inner calls independently, then verify they are + // embedded in the multicall payload. This avoids decoding the complex bytes[] + // ABI structure and directly verifies what matters: the right calls are present. + + it('should contain the expected inner delegation call for each token', () => { + const inner1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY).slice(2); + const inner2 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_2, EXPIRY).slice(2); + const inner3 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_3, EXPIRY).slice(2); + + const calldata = buildMulticallDelegationCalldata( + DELEGATE_ADDRESS, + [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3], + EXPIRY + ); + + calldata.should.containEql(inner1); + calldata.should.containEql(inner2); + calldata.should.containEql(inner3); + }); + + it('single-token multicall should contain its inner call but not others', () => { + const inner1 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY).slice(2); + const inner2 = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS_2, EXPIRY).slice(2); + + const single = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + + single.should.containEql(inner1); + single.should.not.containEql(inner2); + }); + + it('each inner call should start with delegateForUserDecryption selector', () => { + const tokens = [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3]; + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, tokens, EXPIRY); + + for (const token of tokens) { + const expectedInner = buildDelegationCalldata(DELEGATE_ADDRESS, token, EXPIRY).slice(2); + // Each inner call starts with delegateForUserDecryption selector + expectedInner.should.startWith(delegateForUserDecryptionMethodId.slice(2)); + calldata.should.containEql(expectedInner); + } + }); + + it('different expiry timestamps change all inner calls', () => { + const c1 = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + const c2 = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY + 86400); + + // Inner calls with old expiry should NOT appear in the new multicall + const inner1Old = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY).slice(2); + c2.should.not.containEql(inner1Old); + + // And vice versa + const inner1New = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY + 86400).slice(2); + c1.should.not.containEql(inner1New); + }); + + it('should produce longer calldata for more tokens', () => { + const single = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const double = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + const triple = buildMulticallDelegationCalldata( + DELEGATE_ADDRESS, + [TOKEN_ADDRESS, TOKEN_ADDRESS_2, TOKEN_ADDRESS_3], + EXPIRY + ); + double.length.should.be.greaterThan(single.length); + triple.length.should.be.greaterThan(double.length); + }); + + it('different token orderings should produce different calldata', () => { + const c1 = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + const c2 = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS_2, TOKEN_ADDRESS], EXPIRY); + c1.should.not.equal(c2); + }); + }); + + describe('error handling', () => { + it('should throw when given an empty token array', () => { + should.throws( + () => buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [], EXPIRY), + /tokenContractAddresses must not be empty/ + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + describe('wrapInCallFromParent', () => { + describe('output format', () => { + it('should produce a 0x-prefixed hex string', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + wrapped.should.be.a.String(); + wrapped.should.startWith('0x'); + }); + + it('should start with callFromParent selector', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + wrapped.slice(0, 10).should.equal(callFromParentMethodId); + }); + + it('should produce longer calldata than the inner calldata', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(FORWARDER_ADDRESS, inner); + wrapped.length.should.be.greaterThan(inner.length); + }); + }); + + describe('ABI parameter decoding', () => { + it('should decode target address correctly', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + const payload = Buffer.from(wrapped.slice(10), 'hex'); + const [target] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + (target as Buffer).toString('hex').should.equal(ACL_ADDRESS.slice(2).toLowerCase()); + }); + + it('should encode value as 0 (no ETH transfer)', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + const payload = Buffer.from(wrapped.slice(10), 'hex'); + const [, value] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + value.toString().should.equal('0'); + }); + + it('should preserve inner calldata exactly', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + const payload = Buffer.from(wrapped.slice(10), 'hex'); + const [, , data] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + ('0x' + (data as Buffer).toString('hex')).should.equal(inner); + }); + + it('should preserve multicall inner calldata exactly', () => { + const inner = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + const payload = Buffer.from(wrapped.slice(10), 'hex'); + const [, , data] = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + ('0x' + (data as Buffer).toString('hex')).should.equal(inner); + }); + }); + + describe('target address isolation', () => { + it('different target addresses should produce different calldata', () => { + const inner = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const w1 = wrapInCallFromParent(ACL_ADDRESS, inner); + const w2 = wrapInCallFromParent(FORWARDER_ADDRESS, inner); + w1.should.not.equal(w2); + }); + }); + }); +}); diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index 97690b2afa..d38cf6440b 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -4,7 +4,7 @@ import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; import { coins, Erc7984TokenConfig, tokens } from '@bitgo/statics'; -import { CoinNames } from '@bitgo/abstract-eth'; +import { CoinNames, DecryptionDelegationBuilder } from '@bitgo/abstract-eth'; import { Eth } from './eth'; import { TransactionBuilder } from './lib'; @@ -45,39 +45,39 @@ export class Erc7984Token extends Eth { return tokensCtors; } - get type() { + get type(): string { return this.tokenConfig.type; } - get name() { + get name(): string { return this.tokenConfig.name; } - get coin() { + get coin(): string { return this.tokenConfig.coin; } - get network() { + get network(): string { return this.tokenConfig.network; } - get tokenContractAddress() { + get tokenContractAddress(): string { return this.tokenConfig.tokenContractAddress; } - get decimalPlaces() { + get decimalPlaces(): number { return this.tokenConfig.decimalPlaces; } - getChain() { + getChain(): string { return this.tokenConfig.type; } - getFullName() { + getFullName(): string { return 'ERC7984 Confidential Token'; } - getBaseFactor() { + getBaseFactor(): number { return Math.pow(10, this.tokenConfig.decimalPlaces); } @@ -85,14 +85,18 @@ export class Erc7984Token extends Eth { * Flag for sending value of 0. * ERC-7984 confidential transfers always carry an encrypted amount; zero-value sends are not meaningful. */ - valuelessTransferAllowed() { + valuelessTransferAllowed(): boolean { return false; } /** - * Flag for sending data along with transactions. + * Flag for sending data along with transactions via the standard token-send API. + * Returns false because ERC-7984 sends use confidentialTransfer() calldata built + * by WP, not an arbitrary data field on the send params. + * Note: this does not prevent calldata-based flows like getDelegationBuilder(), + * which bypass the token-send path entirely. */ - transactionDataAllowed() { + transactionDataAllowed(): boolean { return false; } @@ -109,4 +113,25 @@ export class Erc7984Token extends Eth { protected getTransactionBuilder(): TransactionBuilder { return new TransactionBuilder(coins.get(this.getBaseChain())); } + + /** + * Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption + * delegation transactions. + * + * The builder produces a DecryptionDelegationTxRequest {to, data, value} that is + * wallet-type-agnostic — WP routes it to the correct signing path: + * - MPC: submit as a raw TSS transaction + * - Multisig: wrap in sendMultiSig(walletContract, to, 0, data, ...) + * + * Example: + * const req = coin.getDecryptionDelegationBuilder().build({ + * aclContractAddress: '0xf0Ff...', + * delegateAddress: enterpriseViewingKey, + * tokenContractAddresses: [tokenAddress], + * expiryTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400, + * }); + */ + getDecryptionDelegationBuilder(): DecryptionDelegationBuilder { + return new DecryptionDelegationBuilder(); + } } diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegation.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegation.ts new file mode 100644 index 0000000000..8fdf6bb8c5 --- /dev/null +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegation.ts @@ -0,0 +1,65 @@ +/** + * Standalone calldata tests for Zama ERC-7984 decryption delegation. + * + * Zama helpers are imported from the @bitgo/abstract-eth public package entry (re-exports + * ./lib including zamaUtils). TransactionType is imported from the sdk-core enum dist path + * to avoid loading the full @bitgo/sdk-core barrel (pre-existing io-ts issue in the TSS graph). + * + * TransactionBuilder build/rebuild tests live in decryptionDelegationTxBuilder.ts. + */ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { + buildMulticallDelegationCalldata, + callFromParentMethodId, + aclMulticallMethodId, + wrapInCallFromParent, +} from '@bitgo/abstract-eth'; + +const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491'; +const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; +const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + +describe('DecryptionDelegation TransactionType (standalone)', () => { + it('should be a valid numeric type distinct from ContractCall/Send/FlushTokens', () => { + should.exist(TransactionType.DecryptionDelegation); + (typeof TransactionType.DecryptionDelegation).should.equal('number'); + TransactionType.DecryptionDelegation.should.not.equal(TransactionType.ContractCall); + TransactionType.DecryptionDelegation.should.not.equal(TransactionType.Send); + TransactionType.DecryptionDelegation.should.not.equal(TransactionType.FlushTokens); + TransactionType.DecryptionDelegation.should.not.equal(TransactionType.FlushERC721); + TransactionType.DecryptionDelegation.should.not.equal(TransactionType.FlushERC1155); + }); +}); + +describe('DecryptionDelegation calldata selectors (standalone)', () => { + it('single-token calldata uses multicall selector 0xac9650d8', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + calldata.slice(0, 10).should.equal(aclMulticallMethodId); + calldata.slice(0, 10).should.equal('0xac9650d8'); + }); + + it('multi-token calldata uses multicall selector 0xac9650d8', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + calldata.slice(0, 10).should.equal(aclMulticallMethodId); + }); + + it('forwarder-wrapped calldata uses callFromParent selector 0x77e60b35', () => { + const inner = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const wrapped = wrapInCallFromParent(ACL_ADDRESS, inner); + wrapped.slice(0, 10).should.equal(callFromParentMethodId); + wrapped.slice(0, 10).should.equal('0x77e60b35'); + }); + + it('delegate address is ABI-encoded in calldata', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + calldata.should.containEql(DELEGATE_ADDRESS.slice(2).toLowerCase().padStart(64, '0')); + }); + + it('token address is ABI-encoded in calldata', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + calldata.should.containEql(TOKEN_ADDRESS.slice(2).toLowerCase().padStart(64, '0')); + }); +}); diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegationTxBuilder.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegationTxBuilder.ts new file mode 100644 index 0000000000..53f6f9a995 --- /dev/null +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/decryptionDelegationTxBuilder.ts @@ -0,0 +1,169 @@ +/** + * TransactionBuilder build and rebuild tests for Zama ERC-7984 decryption delegation. + * + * Pattern mirrors contractCall.ts: imports TransactionType from @bitgo/sdk-core and + * TransactionBuilder from '../../../src'. These are subject to the same pre-existing + * io-ts crash (sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2) that affects ALL + * TransactionBuilder tests in this monorepo. They run in the full CI test suite. + * + * Three flows verified: + * Flow 1 — root wallet: ContractCall + multicall data → build → from(hex) → rebuild + * Flow 2 — explicit DecryptionDelegation type → build → from(hex) → rebuild + * Flow 3 — forwarder: ContractCall + callFromParent data → build → from(hex) → rebuild + */ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder } from '../../../src'; +import { + buildMulticallDelegationCalldata, + wrapInCallFromParent, + aclMulticallMethodId, + callFromParentMethodId, +} from '@bitgo/abstract-eth'; +import { getBuilder } from '../getBuilder'; + +const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; +const DELEGATE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const TOKEN_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const TOKEN_ADDRESS_2 = '0x4E7B06D78965594eB5EF5414c357ca21E1554491'; +const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; +const EXPIRY = Math.floor(Date.now() / 1000) + 365 * 86400; + +describe('DecryptionDelegation TransactionBuilder', () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + // ---- Flow 1: root wallet, ContractCall type -------------------------------- + + it('Flow 1: should build a ContractCall tx with multicall delegation data', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(ACL_ADDRESS.toLowerCase()); + json.data.should.startWith(aclMulticallMethodId); + json.value.should.equal('0'); + }); + + it('Flow 1: should rebuild from hex — multicall delegation round-trip', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS, TOKEN_ADDRESS_2], EXPIRY); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder('hteth') as TransactionBuilder; + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + rebuiltTx.toJson().to.should.equal(ACL_ADDRESS.toLowerCase()); + rebuiltTx.toJson().data.should.startWith(aclMulticallMethodId); + }); + + // ---- Flow 2: explicit DecryptionDelegation type --------------------------- + + it('Flow 2: should build with explicit DecryptionDelegation type', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(ACL_ADDRESS.toLowerCase()); + json.data.should.startWith(aclMulticallMethodId); + json.value.should.equal('0'); + }); + + it('Flow 2: should rebuild from hex — DecryptionDelegation type round-trip', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + txBuilder.data(calldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + // classifyTransaction sees 0xac9650d8 → ContractCall (multicall not mapped) + // ContractCall rebuild path handles it identically to DecryptionDelegation + const rebuiltBuilder = getBuilder('hteth') as TransactionBuilder; + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + }); + + // ---- Flow 3: forwarder callFromParent path -------------------------------- + + it('Flow 3: should build a forwarder delegation tx targeting the forwarder', async () => { + const innerCalldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const outerCalldata = wrapInCallFromParent(ACL_ADDRESS, innerCalldata); + + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.data(outerCalldata); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.ContractCall); + json.to.should.equal(FORWARDER_ADDRESS.toLowerCase()); + json.data.should.startWith(callFromParentMethodId); + json.value.should.equal('0'); + }); + + it('Flow 3: should rebuild from hex — forwarder delegation round-trip', async () => { + const innerCalldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + const outerCalldata = wrapInCallFromParent(ACL_ADDRESS, innerCalldata); + + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.data(outerCalldata); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder('hteth') as TransactionBuilder; + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.toBroadcastFormat(), rawHex); + rebuiltTx.toJson().to.should.equal(FORWARDER_ADDRESS.toLowerCase()); + rebuiltTx.toJson().data.should.startWith(callFromParentMethodId); + }); + + // ---- Validation ----------------------------------------------------------- + + it('should throw for DecryptionDelegation when contract address is missing', async () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.data(calldata); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing contract address'); + }); + + it('should throw for DecryptionDelegation when data field is missing', async () => { + txBuilder.type(TransactionType.DecryptionDelegation); + txBuilder.contract(ACL_ADDRESS); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing contract call data field'); + }); + + it('should throw when data() is called before type is set to a contract call type', () => { + const calldata = buildMulticallDelegationCalldata(DELEGATE_ADDRESS, [TOKEN_ADDRESS], EXPIRY); + should.throws(() => txBuilder.data(calldata), /data can only be set for contract call transaction types/); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 3eb4751e86..a4a8641475 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -134,6 +134,8 @@ export enum TransactionType { // xrp — delete an account and recover the full balance including reserve AccountDelete, + // Delegate decryption access for Zama ERC-7984 confidential tokens via ACL contract + DecryptionDelegation, } /**