From d5f0c0c56e07f6c7770ba8561e894b5a8a2da2e8 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Tue, 2 Jun 2026 18:42:23 +0530 Subject: [PATCH] feat(sdk-coin-eth): add zama token withdrawal support TICKET: CHALO-529 --- .../src/lib/transactionBuilder.ts | 10 +- .../src/lib/transferBuilders/index.ts | 1 + .../transferBuilderERC7984.ts | 133 ++++++ modules/abstract-eth/src/lib/utils.ts | 69 ++++ modules/abstract-eth/src/lib/walletUtil.ts | 7 + modules/sdk-coin-eth/src/erc7984Token.ts | 140 ++++++- .../src/lib/transactionBuilder.ts | 16 +- .../src/lib/transferBuilders/index.ts | 9 +- .../sdk-coin-eth/test/unit/erc7984Token.ts | 364 ++++++++++++++++- .../transactionBuilder/sendERC7984Token.ts | 380 ++++++++++++++++++ .../sdk-core/src/account-lib/baseCoin/enum.ts | 2 + 11 files changed, 1113 insertions(+), 18 deletions(-) create mode 100644 modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC7984.ts create mode 100644 modules/sdk-coin-eth/test/unit/transactionBuilder/sendERC7984Token.ts diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 4719d2581a..99c40e2b61 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -45,6 +45,7 @@ import { import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil'; import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155'; import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721'; +import { TransferBuilderERC7984 } from './transferBuilders/transferBuilderERC7984'; import { Transaction } from './transaction'; import { TransferBuilder } from './transferBuilder'; @@ -77,7 +78,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { private _tokenId: string; // Send and AddressInitialization transaction specific parameters - protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder; + protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; private _contractAddress: string; private _contractCounter: number; private _forwarderVersion: number; @@ -143,6 +144,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.Send: case TransactionType.SendERC721: case TransactionType.SendERC1155: + case TransactionType.SendERC7984: return this.buildSendTransaction(); case TransactionType.AddressInitialization: return this.buildAddressInitializationTransaction(); @@ -270,6 +272,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.Send: case TransactionType.SendERC1155: case TransactionType.SendERC721: + case TransactionType.SendERC7984: this.setContract(transactionJson.to); this._transfer = this.transfer(transactionJson.data, isFirstSigner); break; @@ -406,6 +409,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { case TransactionType.Send: case TransactionType.SendERC721: case TransactionType.SendERC1155: + case TransactionType.SendERC7984: this.validateContractAddress(); break; case TransactionType.AddressInitialization: @@ -673,12 +677,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { * * @param {string} data transfer data to initialize the transfer builder with, empty if none given * @param {boolean} isFirstSigner whether the transaction is being signed by the first signer - * @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder} the transfer builder + * @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984} the transfer builder */ abstract transfer( data?: string, isFirstSigner?: boolean - ): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder; + ): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; /** * Returns the serialized sendMultiSig contract method data diff --git a/modules/abstract-eth/src/lib/transferBuilders/index.ts b/modules/abstract-eth/src/lib/transferBuilders/index.ts index 946d247e92..010bc2dcca 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/index.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/index.ts @@ -1,3 +1,4 @@ export * from './baseNFTTransferBuilder'; export * from './transferBuilderERC1155'; export * from './transferBuilderERC721'; +export * from './transferBuilderERC7984'; diff --git a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC7984.ts b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC7984.ts new file mode 100644 index 0000000000..6b0bd71c9c --- /dev/null +++ b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC7984.ts @@ -0,0 +1,133 @@ +import { BuildTransactionError, InvalidParameterValueError } from '@bitgo/sdk-core'; +import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics'; + +import { ContractCall } from '../contractCall'; +import { decodeConfidentialTransferData, isValidEthAddress, sendMultiSigData } from '../utils'; +import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder'; +import { confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes } from '../walletUtil'; + +export class TransferBuilderERC7984 extends BaseNFTTransferBuilder { + private _encryptedHandle: string; + private _inputProof: string; + /** Plaintext token amount in base units, stored as metadata only (NOT included in calldata). */ + private _amount: string; + + constructor(serializedData?: string) { + super(serializedData); + if (serializedData) { + this.decodeTransferData(serializedData); + } + } + + coin(coin: string): this { + this._coin = coins.get(coin); + this._nativeCoinOperationHashPrefix = (this._coin.network as EthLikeNetwork).nativeCoinOperationHashPrefix; + return this; + } + + tokenContractAddress(address: string): this { + if (isValidEthAddress(address)) { + this._tokenContractAddress = address; + return this; + } + throw new InvalidParameterValueError('Invalid address'); + } + + /** + * Set the plaintext transfer amount in base units. + * + * This value is stored as metadata only — it is NOT included in the on-chain + * calldata, which carries only the encrypted form (encryptedHandle + inputProof). + * Storing it here lets verifyTransaction confirm the signer's intent against + * the original amount the client submitted. + */ + amount(amount: string): this { + if (!/^\d+$/.test(amount) || BigInt(amount) <= 0n) { + throw new InvalidParameterValueError('amount must be a positive integer string in base units'); + } + this._amount = amount; + return this; + } + + /** + * Set the encrypted handle (bytes32 hex from WP) + * Must be a 0x-prefixed 32-byte hex string (66 chars total) + */ + encryptedHandle(handle: string): this { + if (!/^0x[0-9a-fA-F]{64}$/.test(handle)) { + throw new InvalidParameterValueError('encryptedHandle must be a 0x-prefixed 32-byte hex string (66 characters)'); + } + this._encryptedHandle = handle; + return this; + } + + /** + * Set the input proof (bytes hex from WP) + * Must be a 0x-prefixed non-empty hex bytes string + */ + inputProof(proof: string): this { + if (!/^0x[0-9a-fA-F]{2,}$/.test(proof)) { + throw new InvalidParameterValueError('inputProof must be a 0x-prefixed non-empty hex bytes string'); + } + this._inputProof = proof; + return this; + } + + getIsFirstSigner(): boolean { + return false; + } + + build(): string { + this.validateMandatoryFields(); + const contractCall = new ContractCall(confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes, [ + this._toAddress, + this._encryptedHandle, + this._inputProof, + ]); + return contractCall.serialize(); + } + + signAndBuild(chainId: string): string { + if (!Number.isInteger(this._sequenceId)) { + throw new BuildTransactionError('Missing mandatory field: contract sequence id'); + } + this._chainId = chainId; + this.validateMandatoryFields(); + this._data = this.build(); + + return sendMultiSigData( + this._tokenContractAddress, + '0', + this._data, + this._expirationTime, + this._sequenceId, + this.getSignature() + ); + } + + private validateMandatoryFields(): void { + if (!this._toAddress) { + throw new BuildTransactionError('Missing mandatory field: destination (to) address'); + } + if (!this._tokenContractAddress) { + throw new BuildTransactionError('Missing mandatory field: token contract address'); + } + if (!this._encryptedHandle) { + throw new BuildTransactionError('Missing mandatory field: encryptedHandle'); + } + if (!this._inputProof || this._inputProof === '0x' || !/^0x[0-9a-fA-F]{2,}$/.test(this._inputProof)) { + throw new BuildTransactionError('Missing mandatory field: inputProof'); + } + } + + private decodeTransferData(data: string): void { + const transferData = decodeConfidentialTransferData(data); + this._toAddress = transferData.toAddress; + this._tokenContractAddress = transferData.tokenContractAddress; + this._encryptedHandle = transferData.encryptedHandle; + this._inputProof = transferData.inputProof; + this._expirationTime = parseInt(transferData.expireTime, 10); + this._sequenceId = parseInt(transferData.sequenceId, 10); + this._signature = transferData.signature; + } +} diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 31ab5bc38a..96c82282ea 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -83,6 +83,8 @@ import { flushForwarderTokensMethodIdV4, sendMultiSigTokenTypesFirstSigner, sendMultiSigTypesFirstSigner, + confidentialTransferWithProofMethodId, + confidentialTransferWithProofTypes, } from './walletUtil'; import { EthTransactionData } from './types'; import { delegateForUserDecryptionMethodId } from './zamaUtils'; @@ -680,6 +682,59 @@ export function decodeFlushTokensData(data: string, to?: string): FlushTokensDat } } +export interface ConfidentialTransferData { + toAddress: string; + tokenContractAddress: string; + encryptedHandle: string; + inputProof: string; + expireTime: string; + sequenceId: string; + signature: string; +} + +/** + * Decode ABI-encoded confidential transfer data (sendMultiSig wrapping confidentialTransfer) + * + * @param data The full calldata hex string + * @returns parsed confidential transfer fields + */ +export function decodeConfidentialTransferData(data: string): ConfidentialTransferData { + if (!data.startsWith(sendMultisigMethodId)) { + // Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs. + throw new BuildTransactionError( + `Invalid confidential transfer bytecode: unexpected method ID ${data.slice(0, 10)}` + ); + } + + const [tokenContractAddress, , internalData, expireTime, sequenceId, signature] = getRawDecoded( + sendMultiSigTypes, + getBufferedByteCode(sendMultisigMethodId, data) + ); + + const internalDataHex = bufferToHex(internalData as Buffer); + if (!internalDataHex.startsWith(confidentialTransferWithProofMethodId)) { + // Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs. + throw new BuildTransactionError( + `Invalid confidential transfer inner calldata: unexpected method ID ${internalDataHex.slice(0, 10)}` + ); + } + + const [toAddress, encryptedHandle, inputProof] = getRawDecoded( + confidentialTransferWithProofTypes, + getBufferedByteCode(confidentialTransferWithProofMethodId, internalDataHex) + ); + + return { + toAddress: addHexPrefix(toAddress as string), + tokenContractAddress: addHexPrefix(tokenContractAddress as string), + encryptedHandle: bufferToHex(encryptedHandle as Buffer), + inputProof: bufferToHex(inputProof as Buffer), + expireTime: bufferToInt(expireTime as Buffer).toString(), + sequenceId: bufferToInt(sequenceId as Buffer).toString(), + signature: bufferToHex(signature as Buffer), + }; +} + /** * 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 @@ -696,6 +751,20 @@ export function classifyTransaction(data: string): TransactionType { // TODO(STLX-1970): validate if we are going to constraint to some methods allowed let transactionType = transactionTypesMap[data.slice(0, 10).toLowerCase()]; + + // For sendMultiSig transactions, peek at the inner calldata to detect SendERC7984 + if (transactionType === TransactionType.Send && data.startsWith(sendMultisigMethodId)) { + try { + const [, , internalData] = getRawDecoded(sendMultiSigTypes, getBufferedByteCode(sendMultisigMethodId, data)); + const internalDataHex = bufferToHex(internalData as Buffer); + if (internalDataHex.startsWith(confidentialTransferWithProofMethodId)) { + return TransactionType.SendERC7984; + } + } catch { + // Not a confidential transfer; fall through to normal classification + } + } + 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 add5c680f6..d1f4ee03a9 100644 --- a/modules/abstract-eth/src/lib/walletUtil.ts +++ b/modules/abstract-eth/src/lib/walletUtil.ts @@ -45,3 +45,10 @@ export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint2 export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes']; export const createV1ForwarderTypes = ['address', 'bytes32']; export const createV4ForwarderTypes = ['address', 'address', 'bytes32']; + +// keccak256("confidentialTransfer(address,bytes32,bytes)")[0:4] +export const confidentialTransferWithProofMethodId = '0x2fb74e62'; +// keccak256("confidentialTransfer(address,bytes32)")[0:4] — for decoding only +export const confidentialTransferNoProofMethodId = '0x5bebed7e'; +// ABI parameter types for the 3-param version +export const confidentialTransferWithProofTypes = ['address', 'bytes32', 'bytes']; diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index c8ce5a70bf..86c01a2ab3 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -8,6 +8,7 @@ import { CoinNames, DecryptionDelegationBuilder, decodeTokenAddressesFromDelegationCalldata, + decodeConfidentialTransferData, VerifyEthTransactionOptions, aclMulticallMethodId, callFromParentMethodId, @@ -30,7 +31,10 @@ export class Erc7984Token extends Eth { const staticsCoin = coins.get(Erc7984Token.coinNames[tokenConfig.network]); super(bitgo, staticsCoin); this.tokenConfig = tokenConfig; - this.sendMethodName = 'sendMultiSigToken'; + // ERC7984 confidential transfers use sendMultiSig (not sendMultiSigToken) because + // the calldata parameter is required to carry confidentialTransfer(recipient, encryptedHandle, inputProof). + // sendMultiSigToken has no data parameter and cannot carry inner calldata. + this.sendMethodName = 'sendMultiSig'; } static createTokenConstructor(config: Erc7984TokenConfig): CoinConstructor { @@ -126,7 +130,139 @@ export class Erc7984Token extends Eth { if (params.txParams?.type === 'enabletoken') { return this.verifyEnableTokenTransaction(params); } - return super.verifyTransaction(params); + return this.verifyConfidentialTransfer(params); + } + + /** + * 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. + * + * Without txHex (multisig first-signer, MPC pre-signing): + * 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. + * 4. Cross-checks address and amount against buildParams when the server has stored the intent. + */ + private async verifyConfidentialTransfer(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild } = params; + + if (!txPrebuild?.txHex) { + // No raw tx available (multisig first-signer path). + // Validate ERC7984-specific invariants from txParams and buildParams. + const recipients = txParams?.recipients; + if (!recipients || recipients.length === 0) { + throw new Error('verifyConfidentialTransfer: recipients must contain at least one entry'); + } + if (recipients.length !== 1) { + throw new Error( + `verifyConfidentialTransfer: confidential transfers support exactly 1 recipient, got ${recipients.length}` + ); + } + const recipient = recipients[0]; + if (!recipient.address || !this.isValidAddress(recipient.address)) { + throw new Error(`verifyConfidentialTransfer: recipient address is missing or invalid: ${recipient.address}`); + } + const amountStr = String(recipient.amount); + if (!Erc7984Token.isPositiveIntegerString(amountStr)) { + throw new Error( + `verifyConfidentialTransfer: amount must be a positive integer string in base units, got '${amountStr}'` + ); + } + // Cross-check against buildParams when the server has already stored the intent + const buildParamsRecipient = txPrebuild?.buildParams?.recipients?.[0]; + if (buildParamsRecipient?.address !== undefined) { + if (recipient.address.toLowerCase() !== buildParamsRecipient.address.toLowerCase()) { + throw new Error( + `verifyConfidentialTransfer: recipient address mismatch — ` + + `txParams has '${recipient.address}' but buildParams has '${buildParamsRecipient.address}'` + ); + } + } + const buildParamsAmount = buildParamsRecipient?.amount; + if (buildParamsAmount !== undefined && buildParamsAmount !== amountStr) { + throw new Error( + `verifyConfidentialTransfer: amount mismatch — txParams has '${amountStr}' but buildParams has '${buildParamsAmount}'` + ); + } + return true; + } + + // Parse and decode the raw transaction + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + let decoded: ReturnType; + try { + decoded = decodeConfidentialTransferData(txJson.data); + } catch (e) { + throw new Error( + `verifyConfidentialTransfer: failed to decode confidential transfer calldata — ${(e as Error).message}` + ); + } + + // 1. Token contract address must match this coin + if (decoded.tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { + throw new Error( + `verifyConfidentialTransfer: token contract address mismatch — ` + + `expected ${this.tokenContractAddress}, got ${decoded.tokenContractAddress}` + ); + } + + // 2. Recipient address must match txParams.recipients[0] or buildParams.recipients[0] + const expectedRecipient = txParams?.recipients?.[0]?.address ?? txPrebuild.buildParams?.recipients?.[0]?.address; + if (!expectedRecipient) { + throw new Error( + 'verifyConfidentialTransfer: missing expected recipient (provide txParams.recipients or txPrebuild.buildParams.recipients)' + ); + } + if (decoded.toAddress.toLowerCase() !== expectedRecipient.toLowerCase()) { + throw new Error( + `verifyConfidentialTransfer: recipient address mismatch — ` + + `expected ${expectedRecipient}, got ${decoded.toAddress}` + ); + } + + // 3. encryptedHandle must be a non-trivial hex value (not bare '0x') + if (!decoded.encryptedHandle || decoded.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') { + throw new Error('verifyConfidentialTransfer: inputProof is missing or empty in transaction calldata'); + } + + // 5. Verify plaintext intent: txParams amount must be valid and consistent with buildParams + const rawTxParamsAmount = txParams?.recipients?.[0]?.amount; + if (rawTxParamsAmount !== undefined) { + const txParamsAmount = String(rawTxParamsAmount); + if (!Erc7984Token.isPositiveIntegerString(txParamsAmount)) { + throw new Error( + `verifyConfidentialTransfer: amount must be a positive integer string in base units, got '${txParamsAmount}'` + ); + } + const buildParamsAmount = txPrebuild.buildParams?.recipients?.[0]?.amount; + if (buildParamsAmount !== undefined && txParamsAmount !== buildParamsAmount) { + throw new Error( + `verifyConfidentialTransfer: amount mismatch — txParams has '${txParamsAmount}' but buildParams has '${buildParamsAmount}'` + ); + } + } + + return true; + } + + private static isPositiveIntegerString(value: string): boolean { + return /^\d+$/.test(value) && BigInt(value) > 0n; } /** diff --git a/modules/sdk-coin-eth/src/lib/transactionBuilder.ts b/modules/sdk-coin-eth/src/lib/transactionBuilder.ts index bbfea3ef96..c491e8c23b 100644 --- a/modules/sdk-coin-eth/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-eth/src/lib/transactionBuilder.ts @@ -4,12 +4,12 @@ import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; import { TransferBuilder } from './transferBuilder'; import { walletSimpleByteCode } from './walletUtil'; -import { ERC721TransferBuilder, ERC1155TransferBuilder } from './transferBuilders'; +import { ERC721TransferBuilder, ERC1155TransferBuilder, TransferBuilderERC7984 } from './transferBuilders'; /** * Ethereum transaction builder. */ export class TransactionBuilder extends EthLikeTransactionBuilder { - protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder; + protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; private _signatures: any; /** * Public constructor. @@ -28,14 +28,18 @@ export class TransactionBuilder extends EthLikeTransactionBuilder { * * @param {string} data transfer data to initialize the transfer builder with, empty if none given * @param {boolean} isFirstSigner whether the transaction is being signed by the first signer - * @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder} the transfer builder + * @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984} the transfer builder */ - transfer(data?: string, isFirstSigner?: boolean): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder { + transfer( + data?: string, + isFirstSigner?: boolean + ): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984 { if ( !( this._type === TransactionType.Send || this._type === TransactionType.SendERC721 || - this._type === TransactionType.SendERC1155 + this._type === TransactionType.SendERC1155 || + this._type === TransactionType.SendERC7984 ) ) { throw new BuildTransactionError('Transfers can only be set for send transactions'); @@ -46,6 +50,8 @@ export class TransactionBuilder extends EthLikeTransactionBuilder { this._transfer = new ERC721TransferBuilder(data); } else if (this._type === TransactionType.SendERC1155) { this._transfer = new ERC1155TransferBuilder(data); + } else if (this._type === TransactionType.SendERC7984) { + this._transfer = new TransferBuilderERC7984(data); } } return this._transfer; diff --git a/modules/sdk-coin-eth/src/lib/transferBuilders/index.ts b/modules/sdk-coin-eth/src/lib/transferBuilders/index.ts index 6bc0ee382d..efe2abd560 100644 --- a/modules/sdk-coin-eth/src/lib/transferBuilders/index.ts +++ b/modules/sdk-coin-eth/src/lib/transferBuilders/index.ts @@ -1,3 +1,8 @@ -import { BaseNFTTransferBuilder, ERC721TransferBuilder, ERC1155TransferBuilder } from '@bitgo/abstract-eth'; +import { + BaseNFTTransferBuilder, + ERC721TransferBuilder, + ERC1155TransferBuilder, + TransferBuilderERC7984, +} from '@bitgo/abstract-eth'; -export { BaseNFTTransferBuilder, ERC721TransferBuilder, ERC1155TransferBuilder }; +export { BaseNFTTransferBuilder, ERC721TransferBuilder, ERC1155TransferBuilder, TransferBuilderERC7984 }; diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index 0662fdb0c2..50d84a92c3 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -4,6 +4,7 @@ * Covers: * - getTokenEnablementConfig * - verifyTransaction (TSS and multisig paths) + * - verifyTransaction (confidential transfer / SendERC7984 path) * - decodeTokenAddressesFromDelegationCalldata (round-trip and forwarder-wrapped) */ import should from 'should'; @@ -14,11 +15,13 @@ import { buildMulticallDelegationCalldata, wrapInCallFromParent, decodeTokenAddressesFromDelegationCalldata, + TransferBuilderERC7984, } from '@bitgo/abstract-eth'; import { Erc7984Token } from '../../src/erc7984Token'; import { TransactionBuilder } from '../../src/lib'; import { getBuilder } from './getBuilder'; import { register } from '../../src/register'; +import * as testData from '../resources/eth'; // --------------------------------------------------------------------------- // Constants @@ -112,18 +115,17 @@ describe('Erc7984Token', function () { // ------------------------------------------------------------------------- describe('verifyTransaction – non-enable-token', function () { - it('should fall through to parent when type is not enabletoken', async function () { - // The parent verifyTransaction requires recipients, wallet, etc. When we - // pass a params object with no type, it falls into the parent path and - // throws with the parent's "missing params" error — confirming the - // override only intercepts the enabletoken type. + it('should route to confidential transfer path when type is not enabletoken', async function () { + // Non-enabletoken transactions route to verifyConfidentialTransfer. + // Empty recipients triggers our ERC7984-specific validation error, + // confirming the override only intercepts the enabletoken type. await coin .verifyTransaction({ txParams: { recipients: [] }, txPrebuild: {} as any, wallet: {} as any, }) - .should.be.rejectedWith(/missing params/); + .should.be.rejectedWith(/recipients must contain at least one entry/); }); }); @@ -401,6 +403,356 @@ describe('Erc7984Token', function () { }); }); +// --------------------------------------------------------------------------- +// verifyTransaction – confidential transfer (SendERC7984) tests +// --------------------------------------------------------------------------- + +/** + * Build a raw signed sendMultiSig tx hex that wraps a confidentialTransfer call. + * The wallet contract (sendMultiSig wrapper) is `walletContractAddress`. + */ +async function buildConfidentialTransferTxHex(opts: { + walletContractAddress: string; + tokenContractAddress: string; + recipientAddress: string; + encryptedHandle: string; + inputProof: string; +}): Promise { + const key = testData.KEYPAIR_PRV.getKeys().prv as string; + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '12100000' }); + txBuilder.counter(1); + txBuilder.contract(opts.walletContractAddress); + txBuilder.type(TransactionType.SendERC7984); + + const transferBuilder = txBuilder.transfer() as TransferBuilderERC7984; + transferBuilder + .from('0x19645032c7f1533395d44a629462e751084d3e4d') + .to(opts.recipientAddress) + .tokenContractAddress(opts.tokenContractAddress) + .encryptedHandle(opts.encryptedHandle) + .inputProof(opts.inputProof) + .contractSequenceId(1) + .expirationTime(Math.floor(Date.now() / 1000) + 3600) + .key(key); + + txBuilder.sign({ key: testData.PRIVATE_KEY }); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +describe('verifyTransaction – confidential transfer (SendERC7984)', function () { + // Wallet contract that wraps the sendMultiSig call + const WALLET_CONTRACT = '0x8f977e912ef500548a0c3be6ddde9899f1199b81'; + // Recipient of the confidential transfer + const RECIPIENT = '0x19645032c7f1533395d44a629462e751084d3e4c'; + // Encrypted handle: synthetic 32 bytes + const HANDLE = '0x' + 'ab'.repeat(32); + // Input proof: synthetic 50 bytes + const PROOF = '0x' + 'cd'.repeat(50); + // A different recipient to trigger mismatch + const WRONG_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + // A different token contract to trigger mismatch + const WRONG_TOKEN = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + 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; + }); + + // Representative plaintext amount in base units (e.g., 1 cTEST1 = 1_000_000 units at 6 decimals) + const AMOUNT = '1000000'; + + it('should verify a valid confidential transfer transaction with matching amounts', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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 throw when no recipient info is provided in either txParams or buildParams', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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 verify successfully using buildParams.recipients when txParams has no recipients', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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 does not match this coin', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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 buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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 calldata is not a confidential transfer', async function () { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(WALLET_CONTRACT); + txBuilder.data('0xdeadbeef00000000000000000000000000000000000000000000000000000000'); + const tx = await txBuilder.build(); + const txHex = tx.toBroadcastFormat(); + + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { txHex } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/failed to decode confidential transfer calldata/); + }); + + it('should throw when txParams amount does not match buildParams amount (with txHex)', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + 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/); + }); + + it('should throw when txParams amount is 0 (with txHex)', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: '0' }] }, + txPrebuild: { txHex } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/amount must be a positive integer string/); + }); + + // ── First-signer (no txHex) tests ────────────────────────────────────────── + + it('should verify valid first-signer params when no txHex is present', async function () { + const result = await coin.verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: {} as any, + wallet: {} as any, + }); + result.should.equal(true); + }); + + it('should verify first-signer params when txParams and buildParams amounts match', async function () { + const result = await coin.verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] } } as any, + wallet: {} as any, + }); + result.should.equal(true); + }); + + it('should throw when recipients is empty and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [] }, + txPrebuild: {} as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/recipients must contain at least one entry/); + }); + + it('should throw when recipient address is invalid and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: 'not-an-address', amount: AMOUNT }] }, + txPrebuild: {} as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/recipient address is missing or invalid/); + }); + + it('should throw when amount is 0 and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: '0' }] }, + txPrebuild: {} as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/amount must be a positive integer string/); + }); + + it('should throw when amount is empty and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: '' }] }, + txPrebuild: {} as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/amount must be a positive integer string/); + }); + + it('should throw when txParams and buildParams amounts mismatch and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: '9999999' }] }, + txPrebuild: { buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] } } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/amount mismatch/); + }); + + it('should throw when txParams and buildParams recipient addresses mismatch and no txHex is present', async function () { + await coin + .verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { + buildParams: { recipients: [{ address: WRONG_RECIPIENT, amount: AMOUNT }] }, + } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/recipient address mismatch/); + }); + + it('should verify when txHex absent and buildParams has matching recipient address', async function () { + const result = await coin.verifyTransaction({ + txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + txPrebuild: { + buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] }, + } as any, + wallet: {} as any, + }); + result.should.equal(true); + }); + + it('should verify decoded recipient against buildParams when txParams.recipients is absent', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + + // No recipients in txParams — falls back to buildParams for recipient verification + 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 decoded recipient does not match buildParams recipient (txParams absent)', async function () { + const txHex = await buildConfidentialTransferTxHex({ + walletContractAddress: WALLET_CONTRACT, + tokenContractAddress: CTEST1_TOKEN_ADDRESS, + recipientAddress: RECIPIENT, + encryptedHandle: HANDLE, + inputProof: PROOF, + }); + + await coin + .verifyTransaction({ + txParams: {}, + txPrebuild: { + txHex, + buildParams: { recipients: [{ address: WRONG_RECIPIENT, amount: AMOUNT }] }, + } as any, + wallet: {} as any, + }) + .should.be.rejectedWith(/recipient address mismatch/); + }); +}); + // --------------------------------------------------------------------------- // decodeTokenAddressesFromDelegationCalldata tests // --------------------------------------------------------------------------- diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/sendERC7984Token.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/sendERC7984Token.ts new file mode 100644 index 0000000000..82c97ffaf7 --- /dev/null +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/sendERC7984Token.ts @@ -0,0 +1,380 @@ +import { TransactionType } from '@bitgo/sdk-core'; +import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics'; +import { TransferBuilderERC7984, classifyTransaction, decodeConfidentialTransferData } from '@bitgo/abstract-eth'; +import { TransactionBuilder } from '../../../src'; +import * as testData from '../../resources/eth'; +import should from 'should'; +import { getBuilder } from '../getBuilder'; + +describe('Eth transaction builder sendERC7984Token', () => { + // dummy wallet contract address (sendMultiSig wrapper) + const walletContractAddress = '0x8f977e912ef500548a0c3be6ddde9899f1199b81'; + // dummy ERC-7984 token contract address + const tokenContractAddress = '0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85'; + const senderAddress = '0x19645032c7f1533395d44a629462e751084d3e4d'; + const recipientAddress = '0x19645032c7f1533395d44a629462e751084d3e4c'; + + // Synthetic 32-byte encrypted handle (all valid hex) + const encryptedHandle = '0x' + 'ab'.repeat(32); // 66 chars + // Synthetic input proof (~50 bytes) + const inputProof = '0x' + 'cd'.repeat(50); // 102 chars + + const expireTime = 1590066728; + const sequenceId = 42; + + const coin = coins.get('hteth') as unknown as EthLikeNetwork; + + let key: string; + + beforeEach(() => { + key = testData.KEYPAIR_PRV.getKeys().prv as string; + }); + + // ─────────────────────────────────────────────────────────── + // 1. TransferBuilderERC7984 — unit tests + // ─────────────────────────────────────────────────────────── + + describe('TransferBuilderERC7984 unit tests', () => { + it('build() produces correct ABI encoding with expected method ID prefix', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime); + + const innerCalldata = builder.build(); + // confidentialTransfer(address,bytes32,bytes) method id + should(innerCalldata.slice(0, 10).toLowerCase()).equal('0x2fb74e62'); + }); + + it('encryptedHandle setter rejects wrong length', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.encryptedHandle('0x1234')).throw(/encryptedHandle/); + }); + + it('encryptedHandle setter rejects missing 0x prefix', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.encryptedHandle('ab'.repeat(32))).throw(/encryptedHandle/); + }); + + it('inputProof setter rejects empty proof', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.inputProof('0x')).throw(/inputProof/); + }); + + it('inputProof setter rejects non-hex', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.inputProof('0xzzzz')).throw(/inputProof/); + }); + + it('to setter rejects invalid address', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.to('not-an-address')).throw(/Invalid address/); + }); + + it('tokenContractAddress setter rejects invalid address', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.tokenContractAddress('not-an-address')).throw(/Invalid address/); + }); + + it('build() throws without required fields', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.build()).throw(/Missing mandatory field/); + }); + + it('build() throws without encryptedHandle', () => { + const builder = new TransferBuilderERC7984(); + builder.to(recipientAddress).tokenContractAddress(tokenContractAddress).inputProof(inputProof); + should(() => builder.build()).throw(/encryptedHandle/); + }); + + it('build() throws without inputProof', () => { + const builder = new TransferBuilderERC7984(); + builder.to(recipientAddress).tokenContractAddress(tokenContractAddress).encryptedHandle(encryptedHandle); + should(() => builder.build()).throw(/inputProof/); + }); + + it('signAndBuild() throws without contractSequenceId', () => { + // build() only produces inner calldata and does not require sequenceId. + // signAndBuild() wraps in sendMultiSig which needs sequenceId — verify it fails early. + const builder = new TransferBuilderERC7984(); + builder + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .expirationTime(expireTime) + .key(key); + should(() => builder.signAndBuild(`${coin.chainId}`)).throw(/contract sequence id/); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 2. MPC / TSS flow (signing key provided) + // ─────────────────────────────────────────────────────────── + + describe('MPC/TSS flow with signing key', () => { + it('signAndBuild() returns a well-formed hex string', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + const calldata = builder.signAndBuild(`${coin.chainId}`); + should(calldata).be.a.String(); + should(calldata.startsWith('0x')).be.true(); + // outer method id: sendMultiSig = 0x39125215 + should(calldata.slice(0, 10).toLowerCase()).equal('0x39125215'); + }); + + it('decoded inner calldata starts with confidentialTransfer method id and has correct fields', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + const calldata = builder.signAndBuild(`${coin.chainId}`); + const decoded = decodeConfidentialTransferData(calldata); + should(decoded.toAddress.toLowerCase()).equal(recipientAddress.toLowerCase()); + should(decoded.tokenContractAddress.toLowerCase()).equal(tokenContractAddress.toLowerCase()); + should(decoded.encryptedHandle.toLowerCase()).equal(encryptedHandle.toLowerCase()); + should(decoded.inputProof.toLowerCase()).equal(inputProof.toLowerCase()); + should(decoded.expireTime).equal(expireTime.toString()); + should(decoded.sequenceId).equal(sequenceId.toString()); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 3. No-key flow (multisig second-signer path) + // ─────────────────────────────────────────────────────────── + + describe('No-key flow (multisig)', () => { + it('signAndBuild() without key produces calldata with empty signature', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime); + + const calldata = builder.signAndBuild(`${coin.chainId}`); + should(calldata.startsWith('0x39125215')).be.true(); + + // Should still be decodable + const decoded = decodeConfidentialTransferData(calldata); + should(decoded.toAddress.toLowerCase()).equal(recipientAddress.toLowerCase()); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 4. Full TransactionBuilder integration test + // ─────────────────────────────────────────────────────────── + + describe('TransactionBuilder integration', () => { + it('builds a complete SendERC7984 transaction with correct structure', async () => { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ + fee: '1000000000', + gasLimit: '12100000', + }); + txBuilder.counter(2); + txBuilder.contract(walletContractAddress); + txBuilder.type(TransactionType.SendERC7984); + + const transferBuilder = txBuilder.transfer() as TransferBuilderERC7984; + transferBuilder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + txBuilder.sign({ key: testData.PRIVATE_KEY }); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + should(txJson.to.toLowerCase()).equal(walletContractAddress.toLowerCase()); + // Outer data starts with sendMultiSig + should(txJson.data.slice(0, 10).toLowerCase()).equal('0x39125215'); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 5. Round-trip decode test + // ─────────────────────────────────────────────────────────── + + describe('Round-trip decode', () => { + it('decodeConfidentialTransferData() round-trips all fields', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + const calldata = builder.signAndBuild(`${coin.chainId}`); + const decoded = decodeConfidentialTransferData(calldata); + + should(decoded.toAddress.toLowerCase()).equal(recipientAddress.toLowerCase()); + should(decoded.tokenContractAddress.toLowerCase()).equal(tokenContractAddress.toLowerCase()); + should(decoded.encryptedHandle.toLowerCase()).equal(encryptedHandle.toLowerCase()); + should(decoded.inputProof.toLowerCase()).equal(inputProof.toLowerCase()); + should(decoded.expireTime).equal(expireTime.toString()); + should(decoded.sequenceId).equal(sequenceId.toString()); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 6. classifyTransaction() test + // ─────────────────────────────────────────────────────────── + + describe('classifyTransaction()', () => { + it('identifies SendERC7984 transactions correctly', () => { + const builder = new TransferBuilderERC7984(); + builder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + const calldata = builder.signAndBuild(`${coin.chainId}`); + const txType = classifyTransaction(calldata); + should(txType).equal(TransactionType.SendERC7984); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 7. Reconstruction from hex (from() round-trip) + // ─────────────────────────────────────────────────────────── + + describe('Reconstruction from hex', () => { + it('rebuilds SendERC7984 transaction from serialized hex', async () => { + // Build original tx + const txBuilder1 = getBuilder('hteth') as TransactionBuilder; + txBuilder1.fee({ fee: '1000000000', gasLimit: '12100000' }); + txBuilder1.counter(2); + txBuilder1.contract(walletContractAddress); + txBuilder1.type(TransactionType.SendERC7984); + + const transferBuilder = txBuilder1.transfer() as TransferBuilderERC7984; + transferBuilder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + txBuilder1.sign({ key: testData.PRIVATE_KEY }); + const tx1 = await txBuilder1.build(); + const rawHex = tx1.toBroadcastFormat(); + + // Reconstruct from hex + const txBuilder2 = getBuilder('hteth') as TransactionBuilder; + txBuilder2.from(rawHex); + const tx2 = await txBuilder2.build(); + const txJson2 = tx2.toJson(); + + should(txJson2.to.toLowerCase()).equal(walletContractAddress.toLowerCase()); + should(txJson2.data.slice(0, 10).toLowerCase()).equal('0x39125215'); + + // Verify the type was correctly identified + should(txBuilder2['_type']).equal(TransactionType.SendERC7984); + }); + + it('reconstructed _transfer is an instance of TransferBuilderERC7984', async () => { + const txBuilder1 = getBuilder('hteth') as TransactionBuilder; + txBuilder1.fee({ fee: '1000000000', gasLimit: '12100000' }); + txBuilder1.counter(2); + txBuilder1.contract(walletContractAddress); + txBuilder1.type(TransactionType.SendERC7984); + + const transferBuilder = txBuilder1.transfer() as TransferBuilderERC7984; + transferBuilder + .from(senderAddress) + .to(recipientAddress) + .tokenContractAddress(tokenContractAddress) + .encryptedHandle(encryptedHandle) + .inputProof(inputProof) + .contractSequenceId(sequenceId) + .expirationTime(expireTime) + .key(key); + + txBuilder1.sign({ key: testData.PRIVATE_KEY }); + const tx1 = await txBuilder1.build(); + const rawHex = tx1.toBroadcastFormat(); + + const txBuilder2 = getBuilder('hteth') as TransactionBuilder; + txBuilder2.from(rawHex); + + should(txBuilder2['_transfer']).be.instanceof(TransferBuilderERC7984); + }); + }); + + // ─────────────────────────────────────────────────────────── + // 8. Error / edge case tests + // ─────────────────────────────────────────────────────────── + + describe('Error and edge cases', () => { + it('transfer() returns TransferBuilderERC7984 after setting type to SendERC7984', () => { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '12100000' }); + txBuilder.counter(2); + txBuilder.contract(walletContractAddress); + txBuilder.type(TransactionType.Send); // native send type, not ERC7984 + // Native transfer() returns a TransferBuilder, not ERC7984 + // Attempting to use the native transfer for a ERC7984 tx should fail at build time + txBuilder.type(TransactionType.SendERC7984); + // After setting type to ERC7984, calling transfer() should return TransferBuilderERC7984 + const tb = txBuilder.transfer(); + should(tb).be.instanceof(TransferBuilderERC7984); + }); + + it('zero-length inputProof throws on setter', () => { + const builder = new TransferBuilderERC7984(); + should(() => builder.inputProof('0x')).throw(/inputProof/); + }); + + it('encryptedHandle with wrong length throws', () => { + const builder = new TransferBuilderERC7984(); + // 31 bytes instead of 32 + should(() => builder.encryptedHandle('0x' + 'ab'.repeat(31))).throw(/encryptedHandle/); + }); + + it('decodeConfidentialTransferData throws on non-sendMultiSig data', () => { + should(() => decodeConfidentialTransferData('0x12345678')).throw(/Invalid confidential transfer bytecode/); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 4b45b6ef03..f8c3111023 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -148,6 +148,8 @@ export enum TransactionType { SendMPT, // Delegate decryption access for Zama ERC-7984 confidential tokens via ACL contract DecryptionDelegation, + // Send ERC-7984 confidential tokens via encrypted transfer + SendERC7984, } /**