From 96e38d739c3759e86c5fb1308b999c8613ce68c1 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Mon, 29 Jun 2026 15:04:54 +0530 Subject: [PATCH] fix(sdk-coin-eth): fix the verify confidential transfer for zama TICKET: CHALO-696 --- modules/abstract-eth/src/lib/utils.ts | 34 ++++ modules/sdk-coin-eth/src/erc7984Token.ts | 71 ++++++-- .../sdk-coin-eth/test/unit/erc7984Token.ts | 151 ++++++++++++++++++ 3 files changed, 239 insertions(+), 17 deletions(-) diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 7ffa58b8e0..688131b290 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -742,6 +742,40 @@ export function decodeConfidentialTransferData(data: string): ConfidentialTransf }; } +export interface DirectConfidentialTransferData { + toAddress: string; + encryptedHandle: string; + inputProof: string; +} + +/** + * Decode a direct confidentialTransfer(address, bytes32, bytes) calldata. + * + * Used for hot/TSS wallets where the signing address calls the token contract directly, + * without a sendMultiSig wrapper. In this path the `to` field of the EIP-1559 transaction + * is the token contract address and the calldata carries only the three transfer parameters. + * + * @param data 0x-prefixed calldata starting with confidentialTransferWithProofMethodId (0x2fb74e62) + * @returns toAddress, encryptedHandle, inputProof + */ +export function decodeDirectConfidentialTransferCalldata(data: string): DirectConfidentialTransferData { + if (!data.startsWith(confidentialTransferWithProofMethodId)) { + // Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs. + throw new BuildTransactionError( + `Invalid direct confidential transfer calldata: unexpected method ID ${data.slice(0, 10)}` + ); + } + const [toAddress, encryptedHandle, inputProof] = getRawDecoded( + confidentialTransferWithProofTypes, + getBufferedByteCode(confidentialTransferWithProofMethodId, data) + ); + return { + toAddress: addHexPrefix(toAddress as string), + encryptedHandle: bufferToHex(encryptedHandle as Buffer), + inputProof: bufferToHex(inputProof as Buffer), + }; +} + /** * Decode a FlushERC7984ForwarderToken transaction's calldata into its component parts. * diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index 86c01a2ab3..fc9c09e13c 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -9,6 +9,9 @@ import { DecryptionDelegationBuilder, decodeTokenAddressesFromDelegationCalldata, decodeConfidentialTransferData, + decodeDirectConfidentialTransferCalldata, + sendMultisigMethodId, + confidentialTransferWithProofMethodId, VerifyEthTransactionOptions, aclMulticallMethodId, callFromParentMethodId, @@ -136,15 +139,25 @@ export class Erc7984Token extends Eth { /** * Verifies a confidential token transfer (SendERC7984) transaction. * - * With txHex (multisig second-signer, MPC post-signing): - * 1. Decodes the sendMultiSig calldata and checks the inner token contract address. - * 2. Requires the decoded recipient to match txParams.recipients[0].address or - * buildParams.recipients[0].address — at least one must be present. - * 3. Confirms encryptedHandle and inputProof are structurally present. - * 4. Validates txParams.recipients[0].amount is a positive integer and matches - * buildParams.recipients[0].amount when both are present. + * With txHex — two on-chain shapes are supported: * - * Without txHex (multisig first-signer, MPC pre-signing): + * sendMultiSig-wrapped (multisig / smart-contract wallet): + * tx.to = wallet contract + * tx.data = sendMultiSig(tokenAddr, 0, confidentialTransfer(recipient, handle, proof), ...) + * Token contract address is decoded from the sendMultiSig inner calldata. + * + * Direct call (hot / TSS EOA wallet): + * tx.to = token contract + * tx.data = confidentialTransfer(recipient, handle, proof) + * Token contract address is taken from tx.to. + * + * For both shapes the verifier checks: + * 1. Token contract address matches this coin's tokenContractAddress. + * 2. Decoded recipient matches txParams.recipients[0].address or buildParams.recipients[0].address. + * 3. encryptedHandle and inputProof are structurally present (non-empty). + * 4. txParams.recipients[0].amount is a positive integer and matches buildParams when both present. + * + * Without txHex (first-signer / pre-signing path): * 1. Requires exactly one recipient in txParams. * 2. Validates txParams.recipients[0].address is a valid Ethereum address. * 3. Validates txParams.recipients[0].amount is a positive integer. @@ -200,9 +213,34 @@ export class Erc7984Token extends Eth { const tx = await txBuilder.build(); const txJson = tx.toJson(); - let decoded: ReturnType; + let toAddress: string; + let tokenContractAddress: string; + let encryptedHandle: string; + let inputProof: string; + try { - decoded = decodeConfidentialTransferData(txJson.data); + if (txJson.data.startsWith(sendMultisigMethodId)) { + // sendMultiSig-wrapped path: smart-contract wallet relays the confidentialTransfer call. + // Token contract address is encoded inside the sendMultiSig calldata. + const decoded = decodeConfidentialTransferData(txJson.data); + toAddress = decoded.toAddress; + tokenContractAddress = decoded.tokenContractAddress; + encryptedHandle = decoded.encryptedHandle; + inputProof = decoded.inputProof; + } else if (txJson.data.startsWith(confidentialTransferWithProofMethodId)) { + // Direct call path: hot/TSS EOA wallet calls the token contract directly. + // The transaction's `to` field is the token contract address. + if (!txJson.to) { + throw new Error('direct confidentialTransfer call is missing transaction to address'); + } + const decoded = decodeDirectConfidentialTransferCalldata(txJson.data); + toAddress = decoded.toAddress; + tokenContractAddress = txJson.to; + encryptedHandle = decoded.encryptedHandle; + inputProof = decoded.inputProof; + } else { + throw new Error(`unexpected method ID ${txJson.data.slice(0, 10)}`); + } } catch (e) { throw new Error( `verifyConfidentialTransfer: failed to decode confidential transfer calldata — ${(e as Error).message}` @@ -210,10 +248,10 @@ export class Erc7984Token extends Eth { } // 1. Token contract address must match this coin - if (decoded.tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { + if (tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { throw new Error( `verifyConfidentialTransfer: token contract address mismatch — ` + - `expected ${this.tokenContractAddress}, got ${decoded.tokenContractAddress}` + `expected ${this.tokenContractAddress}, got ${tokenContractAddress}` ); } @@ -224,20 +262,19 @@ export class Erc7984Token extends Eth { 'verifyConfidentialTransfer: missing expected recipient (provide txParams.recipients or txPrebuild.buildParams.recipients)' ); } - if (decoded.toAddress.toLowerCase() !== expectedRecipient.toLowerCase()) { + if (toAddress.toLowerCase() !== expectedRecipient.toLowerCase()) { throw new Error( - `verifyConfidentialTransfer: recipient address mismatch — ` + - `expected ${expectedRecipient}, got ${decoded.toAddress}` + `verifyConfidentialTransfer: recipient address mismatch — ` + `expected ${expectedRecipient}, got ${toAddress}` ); } // 3. encryptedHandle must be a non-trivial hex value (not bare '0x') - if (!decoded.encryptedHandle || decoded.encryptedHandle === '0x') { + if (!encryptedHandle || encryptedHandle === '0x') { throw new Error('verifyConfidentialTransfer: encryptedHandle is missing or empty in transaction calldata'); } // 4. inputProof must be a non-trivial hex value - if (!decoded.inputProof || decoded.inputProof === '0x') { + if (!inputProof || inputProof === '0x') { throw new Error('verifyConfidentialTransfer: inputProof is missing or empty in transaction calldata'); } diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index 50d84a92c3..a866452346 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -753,6 +753,157 @@ describe('verifyTransaction – confidential transfer (SendERC7984)', function ( }); }); +// --------------------------------------------------------------------------- +// verifyTransaction – direct confidentialTransfer (hot/TSS EOA wallet) tests +// --------------------------------------------------------------------------- + +/** + * Builds a raw tx hex where the EOA calls confidentialTransfer directly on the token + * contract (no sendMultiSig wrapper). This is the hot/TSS wallet path. + */ +async function buildDirectConfidentialTransferTxHex(opts: { + tokenContractAddress: string; + recipientAddress: string; + encryptedHandle: string; + inputProof: string; +}): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + // tx.to = token contract (EOA calls it directly) + txBuilder.contract(opts.tokenContractAddress); + // Inner calldata: confidentialTransfer(address, bytes32, bytes) — no sendMultiSig wrapper + const calldata = new TransferBuilderERC7984() + .to(opts.recipientAddress) + .tokenContractAddress(opts.tokenContractAddress) + .encryptedHandle(opts.encryptedHandle) + .inputProof(opts.inputProof) + .build(); + txBuilder.data(calldata); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +describe('verifyTransaction – direct confidentialTransfer (hot/TSS EOA wallet)', function () { + const RECIPIENT = '0x19645032c7f1533395d44a629462e751084d3e4c'; + const HANDLE = '0x' + 'ab'.repeat(32); + const PROOF = '0x' + 'cd'.repeat(50); + const WRONG_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const WRONG_TOKEN = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const AMOUNT = '1000000'; + + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + }); + + it('should verify a valid direct confidentialTransfer tx', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + const result = await coin.verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { txHex, buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] } } as any, + wallet: {} as any, + }); + result.should.equal(true); + }); + + it('should verify using buildParams.recipients when txParams has no recipients', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + const result = await coin.verifyTransaction({ + txParams: {}, + txPrebuild: { + txHex, + buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + } as any, + wallet: {} as any, + }); + result.should.equal(true); + }); + + it('should throw when token contract address (tx.to) does not match this coin', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: WRONG_TOKEN, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { txHex } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/token contract address mismatch/); + }); + + it('should throw when recipient address does not match txParams', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: WRONG_RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { txHex } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/recipient address mismatch/); + }); + + it('should throw when no recipient info is provided in either txParams or buildParams', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + await coin + .verifyTransaction({ + txParams: {}, + txPrebuild: { txHex } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/missing expected recipient/); + }); + + it('should throw when txParams amount does not match buildParams amount', async function () { + const txHex = await buildDirectConfidentialTransferTxHex({ + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: '9999999' }] }, + txPrebuild: { + txHex, + buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/amount mismatch/); + }); +}); + // --------------------------------------------------------------------------- // decodeTokenAddressesFromDelegationCalldata tests // ---------------------------------------------------------------------------