diff --git a/modules/express/src/typedRoutes/api/v2/coinSignTx.ts b/modules/express/src/typedRoutes/api/v2/coinSignTx.ts index 55977ed551..4561c81177 100644 --- a/modules/express/src/typedRoutes/api/v2/coinSignTx.ts +++ b/modules/express/src/typedRoutes/api/v2/coinSignTx.ts @@ -13,97 +13,233 @@ export const CoinSignTxParams = { /** * EIP1559 transaction parameters for Ethereum - * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1106 + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:116-119 + * Note: Both fields are REQUIRED when EIP1559 object is provided */ -export const EIP1559 = t.partial({ - /** Maximum fee per gas */ - maxFeePerGas: t.union([t.string, t.number]), - /** Maximum priority fee per gas */ +export const EIP1559 = t.type({ + /** Maximum priority fee per gas (REQUIRED) */ maxPriorityFeePerGas: t.union([t.string, t.number]), + /** Maximum fee per gas (REQUIRED) */ + maxFeePerGas: t.union([t.string, t.number]), }); /** - * Recipient information for a transaction - * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1100-1102 + * Replay protection options for EVM transactions + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:121-124 + * Note: Both fields are REQUIRED when ReplayProtectionOptions object is provided */ -export const Recipient = t.partial({ - /** Recipient address */ - address: t.string, - /** Amount to send */ - amount: t.union([t.string, t.number]), - /** Token name (for token transfers) */ - tokenName: t.string, - /** Additional data */ - data: t.string, +export const ReplayProtectionOptions = t.type({ + /** Chain ID (REQUIRED) */ + chain: t.union([t.string, t.number]), + /** Hardfork name (REQUIRED) */ + hardfork: t.string, }); +/** + * Recipient information for a transaction + * Reference: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:468-472 (Recipient) + * Reference: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:79-84 (ITransactionRecipient) + * Validation: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:622-642 + * + * Note: address and amount are REQUIRED (accessed without null checks in SDK validation) + * tokenName, data, and memo are OPTIONAL + */ +export const Recipient = t.intersection([ + t.type({ + /** Recipient address (REQUIRED) */ + address: t.string, + /** Amount to send (REQUIRED) */ + amount: t.union([t.string, t.number]), + }), + t.partial({ + /** Token name (for token transfers) (OPTIONAL) */ + tokenName: t.string, + /** Additional data (EVM) (OPTIONAL) */ + data: t.string, + /** Memo field (used in ITransactionRecipient for various coins) (OPTIONAL) */ + memo: t.string, + }), +]); + /** * Hop transaction data for Ethereum - * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1110 + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:90-102 (HopPrebuild - full interface) + * Note: All fields are REQUIRED when HopPrebuild object is provided */ -export const HopTransaction = t.partial({ - /** Transaction hex */ - txHex: t.string, - /** User request signature */ +export const HopTransaction = t.type({ + /** Transaction hex (REQUIRED) */ + tx: t.string, + /** Transaction ID (REQUIRED) */ + id: t.string, + /** Signature (REQUIRED) */ + signature: t.string, + /** Payment ID (REQUIRED) */ + paymentId: t.string, + /** Gas price (REQUIRED) */ + gasPrice: t.number, + /** Gas limit (REQUIRED) */ + gasLimit: t.number, + /** Amount to send (REQUIRED) */ + amount: t.number, + /** Recipient address (REQUIRED) */ + recipient: t.string, + /** Transaction nonce (REQUIRED) */ + nonce: t.number, + /** User request signature (REQUIRED) */ userReqSig: t.string, - /** Maximum gas price */ - gasPriceMax: t.union([t.string, t.number]), - /** Gas limit */ - gasLimit: t.union([t.string, t.number]), + /** Maximum gas price (REQUIRED) */ + gasPriceMax: t.number, }); /** * Half-signed transaction data + * + * This covers two use cases: + * 1. Response halfSigned data (txHex, payload, txBase64, txHash) - general coins + * 2. Request txPrebuild.halfSigned for EVM final signing (expireTime, contractSequenceId, backupKeyNonce, signature, txHex) + * + * Reference: + * - Response: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:408-414 + * - Request: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:147-153 (SignFinalOptions.txPrebuild.halfSigned) */ export const HalfSignedData = t.partial({ - /** Transaction hash */ - txHash: t.string, + // From response/general usage (HalfSignedAccountTransaction) + /** Transaction in hex format */ + txHex: t.string, /** Transaction payload */ payload: t.string, /** Transaction in base64 format */ txBase64: t.string, + /** Transaction hash */ + txHash: t.string, + + // From SignFinalOptions.txPrebuild.halfSigned (EVM final signing request) + /** Expiration time (EVM final signing) */ + expireTime: t.number, + /** Contract sequence ID (EVM final signing) */ + contractSequenceId: t.number, + /** Backup key nonce (EVM final signing) */ + backupKeyNonce: t.number, + /** Signature (EVM final signing) */ + signature: t.string, +}); + +/** + * Build parameters structure + * Reference: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:315-318 + */ +export const BuildParams = t.partial({ + /** Preview mode flag */ + preview: t.boolean, + /** Recipients for the transaction */ + recipients: t.array(Recipient), +}); + +/** + * Address information for transaction signing (used by Tron, Tezos, etc.) + * Reference: modules/sdk-coin-trx/src/trx.ts:55-59 + * Note: All fields are REQUIRED when AddressInfo object is provided + */ +export const AddressInfo = t.type({ + /** Address string (REQUIRED) */ + address: t.string, + /** Chain index for address derivation (REQUIRED) */ + chain: t.number, + /** Address index for derivation (REQUIRED) */ + index: t.number, }); /** * Transaction prebuild information - * Reference: modules/abstract-utxo/src/abstractUtxoCoin.ts:336-346 - * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1088-1116 + * + * Base interface: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:327-331 + * EVM extension: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:126-138 + * UTXO extension: modules/abstract-utxo/src/abstractUtxoCoin.ts:248-251 + * + * This codec covers all fields from: + * - SDK Core Base (txBase64, txHex, txInfo, buildParams, consolidateId, txRequestId) + * - EVM-specific (coin, token, nextContractSequenceId, isBatch, eip1559, hopTransaction, etc.) + * - UTXO-specific (blockHeight) + * - Account-based coins (addressInfo, source, feeInfo, keys, addressVersion, etc.) */ export const TransactionPrebuild = t.partial({ + // ============ Base SDK Core fields ============ /** Transaction in hex format */ txHex: t.string, - /** Transaction in base64 format (for some coins like Solana) */ + /** Transaction in base64 format (Solana, Stellar, etc.) */ txBase64: t.string, - /** Transaction info with unspents (for UTXO coins) - coin-specific structure, varies by coin type */ + /** Transaction info with unspents (UTXO coins) - coin-specific structure */ txInfo: t.any, + /** Build parameters including recipients (from BaseSignable) */ + buildParams: BuildParams, + /** Consolidate ID (from BaseSignable) */ + consolidateId: t.string, + /** Transaction request ID for TSS transactions (from BaseSignable) */ + txRequestId: t.string, + + // ============ Universal fields ============ /** Wallet ID for the transaction */ walletId: t.string, - /** Transaction request ID (for TSS transactions) */ - txRequestId: t.string, - /** Consolidate ID */ - consolidateId: t.string, - /** Next contract sequence ID (for ETH) */ + /** Transaction expiration time */ + expireTime: t.number, + /** Half-signed transaction data */ + halfSigned: HalfSignedData, + /** Payload string */ + payload: t.string, + + // ============ EVM-specific fields ============ + /** Coin identifier (EVM - required in EVM interface) */ + coin: t.string, + /** Token identifier (EVM - optional in EVM interface) */ + token: t.string, + /** Next contract sequence ID (EVM) */ nextContractSequenceId: t.number, - /** Whether this is a batch transaction (for ETH) */ + /** Whether this is a batch transaction (EVM) */ isBatch: t.boolean, - /** EIP1559 transaction parameters (for ETH) */ + /** EIP1559 transaction parameters (EVM) */ eip1559: EIP1559, - /** Hop transaction data (for ETH) */ - hopTransaction: HopTransaction, - /** Backup key nonce (for ETH) */ + /** Replay protection options (EVM) */ + replayProtectionOptions: ReplayProtectionOptions, + /** Hop transaction data (EVM) - can be string (in SignFinalOptions) or HopPrebuild object (in TransactionPrebuild) */ + hopTransaction: t.union([t.string, HopTransaction]), + /** Backup key nonce (EVM) */ backupKeyNonce: t.union([t.number, t.string]), /** Recipients of the transaction */ recipients: t.array(Recipient), - /** Gas limit (for EVM chains) */ + /** Gas limit (EVM chains) */ gasLimit: t.union([t.string, t.number]), - /** Gas price (for EVM chains) */ + /** Gas price (EVM chains) */ gasPrice: t.union([t.string, t.number]), - /** Transaction expiration time */ - expireTime: t.number, - /** Half-signed transaction data */ - halfSigned: HalfSignedData, - /** Payload string */ - payload: t.string, + + // ============ UTXO-specific fields ============ + /** Block height (UTXO coins) */ + blockHeight: t.number, + + // ============ Account-based coin specific fields ============ + /** Address information for derivation (Tron, Tezos) - USED in Tron signTransaction */ + addressInfo: AddressInfo, + /** Source address (Solana, Tezos, Hedera, Flare) */ + source: t.string, + /** Fee information (Tron, Tezos, Hedera) - coin-specific structure */ + feeInfo: t.any, + /** Data to sign (Tezos) */ + dataToSign: t.string, + /** Keys array (Algorand) */ + keys: t.array(t.string), + /** Address version (Algorand) */ + addressVersion: t.number, + + // ============ Near-specific fields ============ + /** Key for Near transactions */ + key: t.string, + /** Block hash for Near transactions */ + blockHash: t.string, + /** Nonce for Near transactions (bigint in SDK, but JSON uses number/string) */ + nonce: t.any, + + // ============ Polkadot-specific fields ============ + /** Transaction data for Polkadot */ + transaction: t.any, }); /** @@ -136,6 +272,10 @@ export const CoinSignTxBody = { isEvmBasedCrossChainRecovery: optional(t.boolean), /** Wallet version (for EVM) */ walletVersion: optional(t.number), + /** Signing key nonce for EVM final signing */ + signingKeyNonce: optional(t.number), + /** Wallet contract address for EVM final signing */ + walletContractAddress: optional(t.string), // UTXO-specific fields /** Public keys for multi-signature transactions (xpub triple: user, backup, bitgo) */ @@ -146,6 +286,10 @@ export const CoinSignTxBody = { signingStep: optional(t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')])), /** Allow non-segwit signing without previous transaction (deprecated) */ allowNonSegwitSigningWithoutPrevTx: optional(t.boolean), + + // Solana-specific fields + /** Public keys for Solana transactions */ + pubKeys: optional(t.array(t.string)), } as const; /** @@ -193,8 +337,8 @@ export const HalfSignedAccountTransactionResponse = t.type({ sequenceId: t.number, /** EIP1559 parameters (EVM) */ eip1559: EIP1559, - /** Hop transaction data (EVM) */ - hopTransaction: HopTransaction, + /** Hop transaction data (EVM) - can be string or object */ + hopTransaction: t.union([t.string, HopTransaction]), /** Custodian transaction ID (EVM) */ custodianTransactionId: t.string, /** Whether this is a batch transaction (EVM) */ diff --git a/modules/express/test/unit/typedRoutes/coinSignTx.ts b/modules/express/test/unit/typedRoutes/coinSignTx.ts index e03e679d53..f917172587 100644 --- a/modules/express/test/unit/typedRoutes/coinSignTx.ts +++ b/modules/express/test/unit/typedRoutes/coinSignTx.ts @@ -686,10 +686,7 @@ describe('CoinSignTx codec tests', function () { maxPriorityFeePerGas: '10000000000', maxFeePerGas: '20000000000', }, - hopTransaction: { - txHex: '0x123456', - gasPrice: '20000000000', - }, + hopTransaction: '0x123456abcdef', // String format (from SignFinalOptions) backupKeyNonce: 42, recipients: [ { address: '1abc', amount: 100000 }, @@ -705,7 +702,7 @@ describe('CoinSignTx codec tests', function () { assert.strictEqual(decoded.nextContractSequenceId, validPrebuild.nextContractSequenceId); assert.strictEqual(decoded.isBatch, validPrebuild.isBatch); assert.deepStrictEqual(decoded.eip1559, validPrebuild.eip1559); - assert.deepStrictEqual(decoded.hopTransaction, validPrebuild.hopTransaction); + assert.strictEqual(decoded.hopTransaction, validPrebuild.hopTransaction); // Now string assert.strictEqual(decoded.backupKeyNonce, validPrebuild.backupKeyNonce); assert.deepStrictEqual(decoded.recipients, validPrebuild.recipients); }); @@ -727,6 +724,64 @@ describe('CoinSignTx codec tests', function () { assertDecode(TransactionPrebuild, invalidPrebuild); }); }); + + it('should validate prebuild with hopTransaction as string (EVM)', function () { + const validPrebuild = { + txHex: '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a761202', + hopTransaction: '0x123456abcdef', // String format instead of object + isBatch: false, + }; + + const decoded = assertDecode(TransactionPrebuild, validPrebuild); + assert.strictEqual(decoded.txHex, validPrebuild.txHex); + assert.strictEqual(decoded.hopTransaction, validPrebuild.hopTransaction); + assert.strictEqual(decoded.isBatch, validPrebuild.isBatch); + }); + + it('should validate prebuild with addressInfo (Tron)', function () { + const validPrebuild = { + txHex: '{"raw_data":{"contract":[{"parameter":{"value":{"amount":1000,"to_address":"TTest"}}}]}}', + addressInfo: { + address: 'TTest123456789', + chain: 0, + index: 5, + }, + }; + + const decoded = assertDecode(TransactionPrebuild, validPrebuild); + assert.strictEqual(decoded.txHex, validPrebuild.txHex); + assert.deepStrictEqual(decoded.addressInfo, validPrebuild.addressInfo); + assert.strictEqual(decoded.addressInfo.address, 'TTest123456789'); + assert.strictEqual(decoded.addressInfo.chain, 0); + assert.strictEqual(decoded.addressInfo.index, 5); + }); + + it('should validate prebuild with recipients including memo field', function () { + const validPrebuild = { + txHex: '0x123456', + recipients: [ + { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000', + tokenName: 'USDC', + data: '0xabcdef', + memo: 'Payment for invoice #12345', + }, + { + address: '0x9b9f8e3a7c5b9e1c4a7d6e5f8a9b0c1d2e3f4a5b', + amount: 500000, + memo: 'Refund', + }, + ], + }; + + const decoded = assertDecode(TransactionPrebuild, validPrebuild); + assert.deepStrictEqual(decoded.recipients, validPrebuild.recipients); + assert.strictEqual(decoded.recipients[0].memo, 'Payment for invoice #12345'); + assert.strictEqual(decoded.recipients[1].memo, 'Refund'); + assert.strictEqual(decoded.recipients[0].tokenName, 'USDC'); + assert.strictEqual(decoded.recipients[0].data, '0xabcdef'); + }); }); describe('CoinSignTxBody', function () { @@ -907,12 +962,7 @@ describe('CoinSignTx codec tests', function () { expireTime: 1700000000, contractSequenceId: 42, sequenceId: 5, - hopTransaction: { - txHex: '0x123456', - userReqSig: '0xabcdef', - gasPriceMax: 3000000000, - gasLimit: 21000, - }, + hopTransaction: '0x123456abcdef789', // String format (from SignFinalOptions) custodianTransactionId: 'custodian-tx-12345', isBatch: false, }, @@ -926,7 +976,7 @@ describe('CoinSignTx codec tests', function () { assert.strictEqual(decoded.halfSigned.expireTime, validResponse.halfSigned.expireTime); assert.strictEqual(decoded.halfSigned.contractSequenceId, validResponse.halfSigned.contractSequenceId); assert.strictEqual(decoded.halfSigned.sequenceId, validResponse.halfSigned.sequenceId); - assert.deepStrictEqual(decoded.halfSigned.hopTransaction, validResponse.halfSigned.hopTransaction); + assert.strictEqual(decoded.halfSigned.hopTransaction, validResponse.halfSigned.hopTransaction); // Now string assert.strictEqual(decoded.halfSigned.custodianTransactionId, validResponse.halfSigned.custodianTransactionId); assert.strictEqual(decoded.halfSigned.isBatch, validResponse.halfSigned.isBatch); }); @@ -954,6 +1004,47 @@ describe('CoinSignTx codec tests', function () { const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); assert.deepStrictEqual(decoded.halfSigned, {}); }); + + it('should validate response with hopTransaction as string (EVM)', function () { + const validResponse = { + halfSigned: { + txHex: '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a761202', + hopTransaction: '0x123456abcdef789', // String format (from SignFinalOptions) + contractSequenceId: 42, + sequenceId: 5, + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.txHex, validResponse.halfSigned.txHex); + assert.strictEqual(decoded.halfSigned.hopTransaction, '0x123456abcdef789'); + assert.strictEqual(decoded.halfSigned.contractSequenceId, validResponse.halfSigned.contractSequenceId); + assert.strictEqual(decoded.halfSigned.sequenceId, validResponse.halfSigned.sequenceId); + }); + + it('should validate response with recipients including memo field (EVM)', function () { + const validResponse = { + halfSigned: { + txHex: '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a761202', + recipients: [ + { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + amount: '1000000', + tokenName: 'USDC', + data: '0xabcdef', + memo: 'Payment for services', + }, + ], + contractSequenceId: 10, + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.deepStrictEqual(decoded.halfSigned.recipients, validResponse.halfSigned.recipients); + assert.strictEqual(decoded.halfSigned.recipients[0].memo, 'Payment for services'); + assert.strictEqual(decoded.halfSigned.recipients[0].tokenName, 'USDC'); + assert.strictEqual(decoded.halfSigned.recipients[0].data, '0xabcdef'); + }); }); describe('HalfSignedUtxoTransactionResponse', function () {