From aaf17b7faa485406fee994f8187a6bbc763af125 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Mon, 22 Jun 2026 19:45:12 +0530 Subject: [PATCH] feat(abstract-eth): add zama token consolidation support TICKET: CHALO-606 --- modules/abstract-eth/src/lib/iface.ts | 7 + .../src/lib/transactionBuilder.ts | 114 +++++- modules/abstract-eth/src/lib/utils.ts | 42 ++- modules/abstract-eth/src/lib/walletUtil.ts | 2 + modules/abstract-eth/src/lib/zamaUtils.ts | 82 ++++- .../unit/transactionBuilder/flushERC7984.ts | 342 ++++++++++++++++++ .../test/unit/transactionBuilder/index.ts | 1 + modules/abstract-eth/test/unit/zamaUtils.ts | 132 +++++++ .../unit/transactionBuilder/flushTokens.ts | 7 +- .../sdk-core/src/account-lib/baseCoin/enum.ts | 3 + 10 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 modules/abstract-eth/test/unit/transactionBuilder/flushERC7984.ts diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index 21f482051c..02f92e61e9 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -159,3 +159,10 @@ export interface ForwarderInitializationData { addressCreationSalt?: string; feeAddress?: string; } + +export interface FlushERC7984ForwarderTokenData { + forwarderAddress: string; + tokenContractAddress: string; + encryptedHandle: string; // bytes32 hex + parentAddress: string; +} diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 99c40e2b61..72fcd52e50 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -20,7 +20,7 @@ import { } from '@bitgo/sdk-core'; import { KeyPair } from './keyPair'; -import { ETHTransactionType, Fee, SignatureParts, TxData } from './iface'; +import { ETHTransactionType, Fee, FlushERC7984ForwarderTokenData, SignatureParts, TxData } from './iface'; import { calculateForwarderAddress, calculateForwarderV1Address, @@ -29,6 +29,7 @@ import { decodeFlushTokensData, decodeFlushERC721TokensData, decodeFlushERC1155TokensData, + decodeFlushERC7984ForwarderTokenData, decodeWalletCreationData, flushCoinsData, flushTokensData, @@ -42,6 +43,7 @@ import { getV1WalletInitializationData, getCreateForwarderParamsAndTypes, } from './utils'; +import { buildFlushERC7984ForwarderTokenCalldata } from './zamaUtils'; import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil'; import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155'; import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721'; @@ -77,6 +79,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { private _tokenAddress: string; private _tokenId: string; + // FlushERC7984ForwarderToken parameters + private _tokenContractAddress: string; + private _encryptedHandle: string; // bytes32 hex from confidentialBalanceOf + private _parentAddress: string; // where flushed tokens go (wallet base address) + // Send and AddressInitialization transaction specific parameters protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; private _contractAddress: string; @@ -161,6 +168,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.ContractCall: case TransactionType.DecryptionDelegation: return this.buildGenericContractCallTransaction(); + case TransactionType.FlushERC7984ForwarderToken: + return this.buildFlushERC7984ForwarderTokenTransaction(); default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -303,6 +312,18 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.setContract(transactionJson.to); this.data(transactionJson.data); break; + case TransactionType.FlushERC7984ForwarderToken: { + this.setContract(transactionJson.to); + const erc7984Data: FlushERC7984ForwarderTokenData = decodeFlushERC7984ForwarderTokenData( + transactionJson.data, + transactionJson.to! + ); + this.forwarderAddress(erc7984Data.forwarderAddress); + this.tokenContractAddress(erc7984Data.tokenContractAddress); + this.encryptedHandle(erc7984Data.encryptedHandle); + this.parentAddress(erc7984Data.parentAddress); + break; + } default: throw new BuildTransactionError('Unsupported transaction type'); // TODO: Add other cases of deserialization @@ -454,6 +475,13 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.validateContractAddress(); this.validateDataField(); break; + case TransactionType.FlushERC7984ForwarderToken: + this.validateContractAddress(); + this.validateForwarderAddress(); + this.validateTokenContractAddress(); + this.validateEncryptedHandle(); + this.validateParentAddress(); + break; default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -510,6 +538,33 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + /** + * Check if a token contract address for ERC-7984 flush was defined or throw. + */ + private validateTokenContractAddress(): void { + if (!this._tokenContractAddress) { + throw new BuildTransactionError('Invalid transaction: missing tokenContractAddress'); + } + } + + /** + * Check if an encrypted handle for ERC-7984 flush was defined or throw. + */ + private validateEncryptedHandle(): void { + if (!this._encryptedHandle) { + throw new BuildTransactionError('Invalid transaction: missing encryptedHandle'); + } + } + + /** + * Check if a parent address for ERC-7984 flush was defined or throw. + */ + private validateParentAddress(): void { + if (!this._parentAddress) { + throw new BuildTransactionError('Invalid transaction: missing parentAddress'); + } + } + private setContract(address: string | undefined): void { if (address === undefined) { throw new BuildTransactionError('Undefined recipient address'); @@ -982,4 +1037,61 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { public getWalletVersion(): number { return this._walletVersion; } + + // region FlushERC7984ForwarderToken builder methods + + /** + * Set the ERC-7984 token contract address for forwarder flush transactions. + * + * @param {string} address The ERC-7984 token contract address + */ + tokenContractAddress(address: string): void { + if (!isValidEthAddress(address)) { + throw new BuildTransactionError('Invalid address: ' + address); + } + this._tokenContractAddress = address; + } + + /** + * Set the encrypted balance handle (bytes32 hex) for forwarder flush transactions. + * This is the opaque handle returned by confidentialBalanceOf() on the ERC-7984 token contract. + * + * @param {string} handle The bytes32 hex encrypted balance handle + */ + encryptedHandle(handle: string): void { + this._encryptedHandle = handle; + } + + /** + * Set the parent address (root wallet) for ERC-7984 forwarder flush transactions. + * The flushed tokens will be transferred to this address. + * + * @param {string} address The parent wallet address + */ + parentAddress(address: string): void { + if (!isValidEthAddress(address)) { + throw new BuildTransactionError('Invalid address: ' + address); + } + this._parentAddress = address; + } + + /** + * Build a transaction to flush ERC-7984 tokens from a V4 forwarder to the parent wallet. + * Encodes: callFromParent(tokenContractAddress, 0, confidentialTransfer(parentAddress, encryptedHandle)) + * + * @returns {TxData} The Ethereum transaction data + */ + private buildFlushERC7984ForwarderTokenTransaction(): TxData { + if (this._contractAddress !== this._forwarderAddress) { + throw new BuildTransactionError('contractAddress must equal forwarderAddress for FlushERC7984ForwarderToken'); + } + const data = buildFlushERC7984ForwarderTokenCalldata( + this._tokenContractAddress, + this._parentAddress, + this._encryptedHandle + ); + return this.buildBase(data); + } + + // endregion } diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 96c82282ea..7ffa58b8e0 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -34,6 +34,7 @@ import { ERC1155TransferData, ERC721TransferData, FlushTokensData, + FlushERC7984ForwarderTokenData, NativeTransferData, SignatureParts, TokenTransferData, @@ -85,9 +86,15 @@ import { sendMultiSigTypesFirstSigner, confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes, + confidentialTransferNoProofMethodId, } from './walletUtil'; import { EthTransactionData } from './types'; -import { delegateForUserDecryptionMethodId } from './zamaUtils'; +import { + callFromParentMethodId, + callFromParentTypes, + decodeFlushERC7984ForwarderTokenCalldata, + delegateForUserDecryptionMethodId, +} from './zamaUtils'; /** * @param network @@ -735,6 +742,23 @@ export function decodeConfidentialTransferData(data: string): ConfidentialTransf }; } +/** + * Decode a FlushERC7984ForwarderToken transaction's calldata into its component parts. + * + * @param data The full calldata hex string (callFromParent wrapping confidentialTransfer) + * @param to The `to` field of the transaction (equals the forwarder address for V4) + * @returns Decoded fields including forwarderAddress, tokenContractAddress, parentAddress, encryptedHandle + */ +export function decodeFlushERC7984ForwarderTokenData(data: string, to: string): FlushERC7984ForwarderTokenData { + const { tokenContractAddress, parentAddress, encryptedHandle } = decodeFlushERC7984ForwarderTokenCalldata(data); + return { + forwarderAddress: to, + tokenContractAddress, + parentAddress, + encryptedHandle, + }; +} + /** * Classify the given transaction data based as a transaction type. * ETH transactions are defined by the first 8 bytes of the transaction data, also known as the method id @@ -765,6 +789,22 @@ export function classifyTransaction(data: string): TransactionType { } } + // Peek inside callFromParent to detect FlushERC7984ForwarderToken + if (transactionType === undefined && data.startsWith(callFromParentMethodId)) { + try { + const [, , innerData] = getRawDecoded( + [...callFromParentTypes], + getBufferedByteCode(callFromParentMethodId, data) + ); + const innerHex = bufferToHex(innerData as Buffer); + if (innerHex.startsWith(confidentialTransferNoProofMethodId)) { + return TransactionType.FlushERC7984ForwarderToken; + } + } catch { + // Not a confidential flush; fall through to ContractCall + } + } + if (transactionType === undefined) { transactionType = TransactionType.ContractCall; } diff --git a/modules/abstract-eth/src/lib/walletUtil.ts b/modules/abstract-eth/src/lib/walletUtil.ts index d1f4ee03a9..8d5891d016 100644 --- a/modules/abstract-eth/src/lib/walletUtil.ts +++ b/modules/abstract-eth/src/lib/walletUtil.ts @@ -52,3 +52,5 @@ export const confidentialTransferWithProofMethodId = '0x2fb74e62'; export const confidentialTransferNoProofMethodId = '0x5bebed7e'; // ABI parameter types for the 3-param version export const confidentialTransferWithProofTypes = ['address', 'bytes32', 'bytes']; +// ABI parameter types for confidentialTransfer(address, bytes32) — no proof version +export const confidentialTransferNoProofTypes = ['address', 'bytes32']; diff --git a/modules/abstract-eth/src/lib/zamaUtils.ts b/modules/abstract-eth/src/lib/zamaUtils.ts index c3f1361bac..2d83a5ec4d 100644 --- a/modules/abstract-eth/src/lib/zamaUtils.ts +++ b/modules/abstract-eth/src/lib/zamaUtils.ts @@ -1,6 +1,7 @@ -import { addHexPrefix, toBuffer } from 'ethereumjs-util'; +import { addHexPrefix, bufferToHex, toBuffer } from 'ethereumjs-util'; import EthereumAbi from 'ethereumjs-abi'; import { ethers } from 'ethers'; +import { confidentialTransferNoProofMethodId, confidentialTransferNoProofTypes } from './walletUtil'; // --------------------------------------------------------------------------- // Constants @@ -188,3 +189,82 @@ export function decodeTokenAddressesFromDelegationCalldata(calldata: string): st return tokenAddresses; } + +/** + * Encodes confidentialTransfer(address to, bytes32 encryptedHandle) calldata. + * Uses the no-proof variant (selector 0x5bebed7e) — valid when the caller (forwarder) + * is already ACL-allowed on the handle from when it received the tokens. + * + * @param toAddress Address that will receive the tokens + * @param encryptedHandle bytes32 encrypted balance handle from confidentialBalanceOf + * @returns ABI-encoded calldata hex string (0x-prefixed) + */ +export function buildConfidentialTransferByHandleCalldata(toAddress: string, encryptedHandle: string): string { + const method = Buffer.from(confidentialTransferNoProofMethodId.slice(2), 'hex'); + const handleBuffer = toBuffer(encryptedHandle); + const args = EthereumAbi.rawEncode([...confidentialTransferNoProofTypes], [toAddress, handleBuffer]); + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); +} + +/** + * Encodes the full flush calldata for ERC-7984 forwarder consolidation: + * callFromParent(tokenContractAddress, 0, confidentialTransfer(parentAddress, encryptedHandle)) + * The forwarder executes the inner confidentialTransfer with msg.sender = forwarder. + * + * @param tokenContractAddress ERC-7984 token contract address + * @param parentAddress Root wallet address (destination of flushed tokens) + * @param encryptedHandle bytes32 encrypted balance handle from confidentialBalanceOf + * @returns ABI-encoded calldata hex string (0x-prefixed) + */ +export function buildFlushERC7984ForwarderTokenCalldata( + tokenContractAddress: string, + parentAddress: string, + encryptedHandle: string +): string { + const innerCalldata = buildConfidentialTransferByHandleCalldata(parentAddress, encryptedHandle); + return wrapInCallFromParent(tokenContractAddress, innerCalldata); +} + +/** + * Decodes a FlushERC7984ForwarderToken calldata. + * Strips the outer callFromParent wrapper and the inner confidentialTransfer. + * Returns { tokenContractAddress, parentAddress, encryptedHandle }. + * + * @param data ABI-encoded flush calldata (0x-prefixed) + * @returns Decoded fields + */ +export function decodeFlushERC7984ForwarderTokenCalldata(data: string): { + tokenContractAddress: string; + parentAddress: string; + encryptedHandle: string; +} { + if (!data.startsWith(callFromParentMethodId)) { + throw new Error( + `Invalid FlushERC7984ForwarderToken calldata: expected callFromParent selector, got ${data.slice(0, 10)}` + ); + } + + const abiCoder = new ethers.utils.AbiCoder(); + const outerDecoded = abiCoder.decode([...callFromParentTypes], '0x' + data.slice(10)); + const tokenContractAddress: string = outerDecoded[0]; + const innerCalldata: string = ethers.utils.hexlify(outerDecoded[2]); + + if (!innerCalldata.startsWith(confidentialTransferNoProofMethodId)) { + throw new Error( + `Invalid FlushERC7984ForwarderToken inner calldata: expected confidentialTransfer selector, got ${innerCalldata.slice( + 0, + 10 + )}` + ); + } + + const innerDecoded = abiCoder.decode([...confidentialTransferNoProofTypes], '0x' + innerCalldata.slice(10)); + const parentAddress: string = innerDecoded[0]; + const encryptedHandle: string = bufferToHex(toBuffer(innerDecoded[1])); + + return { + tokenContractAddress, + parentAddress, + encryptedHandle, + }; +} diff --git a/modules/abstract-eth/test/unit/transactionBuilder/flushERC7984.ts b/modules/abstract-eth/test/unit/transactionBuilder/flushERC7984.ts new file mode 100644 index 0000000000..f1c13539b8 --- /dev/null +++ b/modules/abstract-eth/test/unit/transactionBuilder/flushERC7984.ts @@ -0,0 +1,342 @@ +/** + * TransactionBuilder tests for FlushERC7984ForwarderToken transaction type. + * + * Verifies: + * - Building a FlushERC7984ForwarderToken tx from scratch (legacy and EIP-1559 fees) + * - Signing and serialization round-trip for both signed and unsigned transactions + * - classifyTransaction correctly identifies the type + * - Error cases for missing fields + * - contractAddress !== forwarderAddress guard + */ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { ETHTransactionType, TransactionBuilder } from '../../../src'; +import { + buildFlushERC7984ForwarderTokenCalldata, + callFromParentMethodId, + decodeFlushERC7984ForwarderTokenCalldata, +} from '../../../src/lib/zamaUtils'; +import { classifyTransaction } from '../../../src/lib/utils'; + +const FORWARDER_ADDRESS = '0xDeADbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; +const TOKEN_CONTRACT_ADDRESS = '0x94167129172A35ab093B44b8b96213DDbc3cD387'; +const PARENT_ADDRESS = '0x1111111111111111111111111111111111111111'; +const ENCRYPTED_HANDLE = '0x' + 'ab'.repeat(32); // 32-byte mock handle +const OTHER_ADDRESS = '0x2222222222222222222222222222222222222222'; +// Deterministic test-only private key — never used on mainnet +const TEST_PRV_KEY = 'FAC4D04AA0025ECF200D74BC9B5E4616E4B8338B69B61362AAAD49F76E68EF28'; + +export function runFlushERC7984Tests(coinName: string, getBuilder: (coin: string) => TransactionBuilder): void { + describe(`${coinName} transaction builder — FlushERC7984ForwarderToken`, () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder(coinName); + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + // ------------------------------------------------------------------------- + // classifyTransaction — verify selector → type mapping + // ------------------------------------------------------------------------- + describe('classifyTransaction', () => { + it('should classify callFromParent+confidentialTransfer as FlushERC7984ForwarderToken', () => { + const calldata = buildFlushERC7984ForwarderTokenCalldata( + TOKEN_CONTRACT_ADDRESS, + PARENT_ADDRESS, + ENCRYPTED_HANDLE + ); + const result = classifyTransaction(calldata); + should.equal(result, TransactionType.FlushERC7984ForwarderToken); + }); + + it('should NOT classify plain callFromParent (delegation) as FlushERC7984ForwarderToken', () => { + // A callFromParent wrapping a multicall delegation starts with 0xac9650d8 inside, not 0x5bebed7e + // Use callFromParent prefix + arbitrary non-confidentialTransfer inner data + const fakeInner = '0xac9650d8' + '00'.repeat(64); // multicall selector + const { wrapInCallFromParent } = require('../../../src/lib/zamaUtils'); + const calldata = wrapInCallFromParent(TOKEN_CONTRACT_ADDRESS, fakeInner); + const result = classifyTransaction(calldata); + // Should fall through to ContractCall, not FlushERC7984ForwarderToken + should.equal(result, TransactionType.ContractCall); + }); + + it('should NOT classify confidentialTransferWithProof inside sendMultiSig as FlushERC7984ForwarderToken', () => { + // SendERC7984 uses sendMultiSig wrapping confidentialTransferWithProof + // Should remain SendERC7984, not FlushERC7984ForwarderToken + const result = classifyTransaction('0x39125215' + '00'.repeat(28)); + should.equal(result, TransactionType.Send); + }); + }); + + // ------------------------------------------------------------------------- + // Build from scratch + // ------------------------------------------------------------------------- + describe('build from scratch', () => { + it('should build a FlushERC7984ForwarderToken transaction', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.FlushERC7984ForwarderToken); + json.to.toLowerCase().should.equal(FORWARDER_ADDRESS.toLowerCase()); + json.data.should.startWith(callFromParentMethodId); + json.value.should.equal('0'); + }); + + it('tx.data should contain the inner confidentialTransfer selector', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + const { tokenContractAddress, parentAddress, encryptedHandle } = decodeFlushERC7984ForwarderTokenCalldata( + json.data + ); + tokenContractAddress.toLowerCase().should.equal(TOKEN_CONTRACT_ADDRESS.toLowerCase()); + parentAddress.toLowerCase().should.equal(PARENT_ADDRESS.toLowerCase()); + encryptedHandle.should.equal(ENCRYPTED_HANDLE); + }); + + it('tx.to should equal the forwarder address', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + const tx = await txBuilder.build(); + tx.toJson().to.toLowerCase().should.equal(FORWARDER_ADDRESS.toLowerCase()); + }); + + it('should build with EIP-1559 fee model', async () => { + const builder = getBuilder(coinName); + builder.fee({ + fee: '30000000000', + eip1559: { + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }, + gasLimit: '200000', + }); + builder.counter(1); + builder.type(TransactionType.FlushERC7984ForwarderToken); + builder.contract(FORWARDER_ADDRESS); + builder.forwarderAddress(FORWARDER_ADDRESS); + builder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + builder.encryptedHandle(ENCRYPTED_HANDLE); + builder.parentAddress(PARENT_ADDRESS); + + const tx = await builder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.FlushERC7984ForwarderToken); + json._type.should.equal(ETHTransactionType.EIP1559); + json.maxFeePerGas!.should.equal('30000000000'); + json.maxPriorityFeePerGas!.should.equal('1000000000'); + should.not.exist((json as any).gasPrice); + json.data.should.startWith(callFromParentMethodId); + }); + }); + + // ------------------------------------------------------------------------- + // Signing + // ------------------------------------------------------------------------- + describe('signing', () => { + it('should produce a signed transaction with v, r, s and from fields', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + txBuilder.sign({ key: TEST_PRV_KEY }); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.exist(json.v); + should.exist(json.r); + should.exist(json.s); + should.exist(json.from); + should.equal(tx.type, TransactionType.FlushERC7984ForwarderToken); + }); + + it('should round-trip a signed transaction from serialized hex', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + txBuilder.sign({ key: TEST_PRV_KEY }); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + rebuiltTx.toBroadcastFormat().should.equal(rawHex); + should.equal(rebuiltTx.type, TransactionType.FlushERC7984ForwarderToken); + should.exist(rebuiltTx.toJson().v); + }); + + it('should produce a signed EIP-1559 transaction with v, r, s fields', async () => { + const builder = getBuilder(coinName); + builder.fee({ + fee: '30000000000', + eip1559: { + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }, + gasLimit: '200000', + }); + builder.counter(1); + builder.type(TransactionType.FlushERC7984ForwarderToken); + builder.contract(FORWARDER_ADDRESS); + builder.forwarderAddress(FORWARDER_ADDRESS); + builder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + builder.encryptedHandle(ENCRYPTED_HANDLE); + builder.parentAddress(PARENT_ADDRESS); + builder.sign({ key: TEST_PRV_KEY }); + + const tx = await builder.build(); + const json = tx.toJson(); + + json._type.should.equal(ETHTransactionType.EIP1559); + should.exist(json.v); + should.exist(json.r); + should.exist(json.s); + should.exist(json.from); + }); + }); + + // ------------------------------------------------------------------------- + // Serialization round-trip + // ------------------------------------------------------------------------- + describe('serialization round-trip', () => { + it('should serialize and deserialize to the same transaction', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + rebuiltTx.toBroadcastFormat().should.equal(rawHex); + }); + + it('deserialized tx should classify as FlushERC7984ForwarderToken', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + should.equal(rebuiltTx.type, TransactionType.FlushERC7984ForwarderToken); + }); + }); + + // ------------------------------------------------------------------------- + // Error cases — missing required fields + // ------------------------------------------------------------------------- + describe('missing field errors', () => { + it('should throw when forwarderAddress is not set', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + // no forwarderAddress + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + await txBuilder.build().should.be.rejectedWith(/missing forwarder address/i); + }); + + it('should throw when tokenContractAddress is not set', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + // no tokenContractAddress + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + await txBuilder.build().should.be.rejectedWith(/missing tokenContractAddress/i); + }); + + it('should throw when encryptedHandle is not set', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + // no encryptedHandle + txBuilder.parentAddress(PARENT_ADDRESS); + + await txBuilder.build().should.be.rejectedWith(/missing encryptedHandle/i); + }); + + it('should throw when parentAddress is not set', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(FORWARDER_ADDRESS); + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + // no parentAddress + + await txBuilder.build().should.be.rejectedWith(/missing parentAddress/i); + }); + + it('should throw when contractAddress !== forwarderAddress', async () => { + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(OTHER_ADDRESS); // different from forwarder + txBuilder.forwarderAddress(FORWARDER_ADDRESS); + txBuilder.tokenContractAddress(TOKEN_CONTRACT_ADDRESS); + txBuilder.encryptedHandle(ENCRYPTED_HANDLE); + txBuilder.parentAddress(PARENT_ADDRESS); + + await txBuilder.build().should.be.rejectedWith(/contractAddress must equal forwarderAddress/); + }); + }); + + // ------------------------------------------------------------------------- + // Setter validation + // ------------------------------------------------------------------------- + describe('setter validation', () => { + it('tokenContractAddress should reject invalid address', () => { + should.throws(() => txBuilder.tokenContractAddress('not-an-address'), /Invalid address/); + }); + + it('parentAddress should reject invalid address', () => { + should.throws(() => txBuilder.parentAddress('not-an-address'), /Invalid address/); + }); + }); + }); +} diff --git a/modules/abstract-eth/test/unit/transactionBuilder/index.ts b/modules/abstract-eth/test/unit/transactionBuilder/index.ts index 64973bde89..ebeed11045 100644 --- a/modules/abstract-eth/test/unit/transactionBuilder/index.ts +++ b/modules/abstract-eth/test/unit/transactionBuilder/index.ts @@ -3,3 +3,4 @@ export * from './send'; export * from './walletInitialization'; export * from './flushNft'; export * from './decryptionDelegation'; +export * from './flushERC7984'; diff --git a/modules/abstract-eth/test/unit/zamaUtils.ts b/modules/abstract-eth/test/unit/zamaUtils.ts index 18a608ad46..b5cb2aafc4 100644 --- a/modules/abstract-eth/test/unit/zamaUtils.ts +++ b/modules/abstract-eth/test/unit/zamaUtils.ts @@ -3,11 +3,15 @@ import EthereumAbi from 'ethereumjs-abi'; import { buildDelegationCalldata, buildMulticallDelegationCalldata, + buildConfidentialTransferByHandleCalldata, + buildFlushERC7984ForwarderTokenCalldata, + decodeFlushERC7984ForwarderTokenCalldata, wrapInCallFromParent, delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId, } from '../../src/lib/zamaUtils'; +import { confidentialTransferNoProofMethodId } from '../../src/lib/walletUtil'; describe('Zama Utils', () => { const ACL_ADDRESS = '0xf0Ffdc93b7E186bC2f8CB3dAA75D86d1930A433D'; @@ -302,4 +306,132 @@ describe('Zama Utils', () => { }); }); }); + + // ------------------------------------------------------------------------- + describe('buildConfidentialTransferByHandleCalldata', () => { + const HANDLE = '0x' + 'ab'.repeat(32); // 32-byte mock handle + + it('should return a 0x-prefixed hex string', () => { + const result = buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, HANDLE); + result.should.be.a.String(); + result.should.startWith('0x'); + }); + + it('should start with the confidentialTransferNoProof selector (0x5bebed7e)', () => { + const result = buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, HANDLE); + result.should.startWith(confidentialTransferNoProofMethodId); + }); + + it('should encode the recipient address correctly', () => { + const result = buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, HANDLE); + const payload = Buffer.from(result.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'bytes32'], payload); + ('0x' + (decoded[0] as Buffer).toString('hex')).toLowerCase().should.equal(DELEGATE_ADDRESS.toLowerCase()); + }); + + it('should encode the encrypted handle correctly', () => { + const result = buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, HANDLE); + const payload = Buffer.from(result.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'bytes32'], payload); + ('0x' + (decoded[1] as Buffer).toString('hex')).should.equal(HANDLE); + }); + + it('different handles should produce different calldata', () => { + const h1 = '0x' + '01'.repeat(32); + const h2 = '0x' + '02'.repeat(32); + buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, h1).should.not.equal( + buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, h2) + ); + }); + + it('different recipient addresses should produce different calldata', () => { + buildConfidentialTransferByHandleCalldata(DELEGATE_ADDRESS, HANDLE).should.not.equal( + buildConfidentialTransferByHandleCalldata(TOKEN_ADDRESS, HANDLE) + ); + }); + }); + + // ------------------------------------------------------------------------- + describe('buildFlushERC7984ForwarderTokenCalldata', () => { + const PARENT_ADDRESS = '0x2222222222222222222222222222222222222222'; + const HANDLE = '0x' + 'cd'.repeat(32); + + it('should return a 0x-prefixed hex string', () => { + const result = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + result.should.be.a.String(); + result.should.startWith('0x'); + }); + + it('should start with the callFromParent selector', () => { + const result = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + result.should.startWith(callFromParentMethodId); + }); + + it('inner calldata should start with confidentialTransferNoProof selector', () => { + const result = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + const payload = Buffer.from(result.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + const innerCalldata = '0x' + (decoded[2] as Buffer).toString('hex'); + innerCalldata.should.startWith(confidentialTransferNoProofMethodId); + }); + + it('outer target address should equal the token contract address', () => { + const result = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + const payload = Buffer.from(result.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + ('0x' + (decoded[0] as Buffer).toString('hex')).toLowerCase().should.equal(TOKEN_ADDRESS.toLowerCase()); + }); + + it('inner recipient should equal the parent address', () => { + const result = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + const payload = Buffer.from(result.slice(10), 'hex'); + const decoded = EthereumAbi.rawDecode(['address', 'uint256', 'bytes'], payload); + const innerCalldata = '0x' + (decoded[2] as Buffer).toString('hex'); + const innerPayload = Buffer.from(innerCalldata.slice(10), 'hex'); + const innerDecoded = EthereumAbi.rawDecode(['address', 'bytes32'], innerPayload); + ('0x' + (innerDecoded[0] as Buffer).toString('hex')).toLowerCase().should.equal(PARENT_ADDRESS.toLowerCase()); + }); + + it('different token addresses should produce different calldata', () => { + buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE).should.not.equal( + buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS_2, PARENT_ADDRESS, HANDLE) + ); + }); + }); + + // ------------------------------------------------------------------------- + describe('decodeFlushERC7984ForwarderTokenCalldata', () => { + const PARENT_ADDRESS = '0x2222222222222222222222222222222222222222'; + const HANDLE = '0x' + 'ef'.repeat(32); + + it('should round-trip encode and decode correctly', () => { + const encoded = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + const decoded = decodeFlushERC7984ForwarderTokenCalldata(encoded); + decoded.tokenContractAddress.toLowerCase().should.equal(TOKEN_ADDRESS.toLowerCase()); + decoded.parentAddress.toLowerCase().should.equal(PARENT_ADDRESS.toLowerCase()); + decoded.encryptedHandle.should.equal(HANDLE); + }); + + it('should throw for calldata with wrong outer selector', () => { + const bad = '0xdeadbeef' + '00'.repeat(64); + should.throws(() => decodeFlushERC7984ForwarderTokenCalldata(bad), /Invalid FlushERC7984ForwarderToken calldata/); + }); + + it('should throw for calldata with correct outer but wrong inner selector', () => { + // Wrap delegation calldata in callFromParent — inner starts with 0x04f61a95, not 0x5bebed7e + const innerCalldata = buildDelegationCalldata(DELEGATE_ADDRESS, TOKEN_ADDRESS, EXPIRY); + const wrapped = wrapInCallFromParent(TOKEN_ADDRESS, innerCalldata); + should.throws( + () => decodeFlushERC7984ForwarderTokenCalldata(wrapped), + /Invalid FlushERC7984ForwarderToken inner calldata/ + ); + }); + + it('decoded tokenContractAddress should be checksummed or lowercase consistently', () => { + const encoded = buildFlushERC7984ForwarderTokenCalldata(TOKEN_ADDRESS, PARENT_ADDRESS, HANDLE); + const decoded = decodeFlushERC7984ForwarderTokenCalldata(encoded); + should.exist(decoded.tokenContractAddress); + decoded.tokenContractAddress.should.be.a.String(); + }); + }); }); diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts index 357e8b5a1d..097127f13f 100644 --- a/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts @@ -10,13 +10,18 @@ import { } from '../../../src'; import { getBuilder } from '../getBuilder'; // eslint-disable-next-line import/no-internal-modules -import { runFlushNftTests } from '@bitgo/abstract-eth/test/unit/transactionBuilder'; +import { runFlushNftTests, runFlushERC7984Tests } from '@bitgo/abstract-eth/test/unit/transactionBuilder'; // Run the shared flush NFT tests from abstract-eth describe('ETH Flush NFT Tests (from abstract-eth)', () => { runFlushNftTests('eth', getBuilder); }); +// Run the shared FlushERC7984ForwarderToken tests from abstract-eth +describe('ETH FlushERC7984ForwarderToken Tests (from abstract-eth)', () => { + runFlushERC7984Tests('eth', getBuilder); +}); + describe('Eth Transaction builder flush tokens (ETH-specific)', function () { const defaultKeyPair = new KeyPair({ prv: 'FAC4D04AA0025ECF200D74BC9B5E4616E4B8338B69B61362AAAD49F76E68EF28', diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 05248d5d74..68cc7c003c 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -152,6 +152,9 @@ export enum TransactionType { DecryptionDelegation, // Send ERC-7984 confidential tokens via encrypted transfer SendERC7984, + // Flush ERC-7984 confidential tokens from a forwarder address to the parent wallet + // via forwarder.callFromParent(tokenAddr, 0, confidentialTransfer(parentAddr, handle)) + FlushERC7984ForwarderToken, } /**