diff --git a/modules/express/src/typedRoutes/api/v2/coinSignTx.ts b/modules/express/src/typedRoutes/api/v2/coinSignTx.ts index fb69678dc5..6bec68685d 100644 --- a/modules/express/src/typedRoutes/api/v2/coinSignTx.ts +++ b/modules/express/src/typedRoutes/api/v2/coinSignTx.ts @@ -11,42 +11,115 @@ export const CoinSignTxParams = { coin: t.string, } as const; +/** + * EIP1559 transaction parameters for Ethereum + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1106 + */ +export const EIP1559 = t.partial({ + /** Maximum fee per gas */ + maxFeePerGas: t.union([t.string, t.number]), + /** Maximum priority fee per gas */ + maxPriorityFeePerGas: t.union([t.string, t.number]), +}); + +/** + * Recipient information for a transaction + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1100-1102 + */ +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, +}); + +/** + * Hop transaction data for Ethereum + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1110 + */ +export const HopTransaction = t.partial({ + /** Transaction hex */ + txHex: t.string, + /** User request signature */ + userReqSig: t.string, + /** Maximum gas price */ + gasPriceMax: t.union([t.string, t.number]), + /** Gas limit */ + gasLimit: t.union([t.string, t.number]), +}); + +/** + * Half-signed transaction data + */ +export const HalfSignedData = t.partial({ + /** Transaction hash */ + txHash: t.string, + /** Transaction payload */ + payload: t.string, + /** Transaction in base64 format */ + txBase64: t.string, +}); + /** * Transaction prebuild information + * Reference: modules/abstract-utxo/src/abstractUtxoCoin.ts:336-346 + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1088-1116 */ export const TransactionPrebuild = t.partial({ /** Transaction in hex format */ txHex: t.string, - /** Transaction in base64 format (for some coins) */ + /** Transaction in base64 format (for some coins like Solana) */ txBase64: t.string, - /** Transaction in JSON format (for some coins) */ + /** Transaction info with unspents (for UTXO coins) - coin-specific structure, varies by coin type */ txInfo: t.any, /** 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) */ nextContractSequenceId: t.number, /** Whether this is a batch transaction (for ETH) */ isBatch: t.boolean, /** EIP1559 transaction parameters (for ETH) */ - eip1559: t.any, + eip1559: EIP1559, /** Hop transaction data (for ETH) */ - hopTransaction: t.any, + hopTransaction: HopTransaction, /** Backup key nonce (for ETH) */ - backupKeyNonce: t.any, + backupKeyNonce: t.union([t.number, t.string]), /** Recipients of the transaction */ - recipients: t.any, + recipients: t.array(Recipient), + /** Gas limit (for EVM chains) */ + gasLimit: t.union([t.string, t.number]), + /** Gas price (for 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, }); /** * Request body for signing a transaction + * Reference: modules/abstract-utxo/src/abstractUtxoCoin.ts:335-362 (UTXO fields) + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:168-177 (EVM fields) */ export const CoinSignTxBody = { - /** Private key for signing */ + /** Private key for signing (universal field) */ prv: optional(t.string), - /** Transaction prebuild data */ + /** Transaction prebuild data (universal field) */ txPrebuild: optional(TransactionPrebuild), /** Whether this is the last signature in a multi-sig tx */ isLastSignature: optional(t.boolean), + + // EVM-specific fields /** Gas limit for ETH transactions */ gasLimit: optional(t.union([t.string, t.number])), /** Gas price for ETH transactions */ @@ -55,17 +128,23 @@ export const CoinSignTxBody = { expireTime: optional(t.number), /** Sequence ID for transactions */ sequenceId: optional(t.number), - /** Public keys for multi-signature transactions */ - pubKeys: optional(t.array(t.string)), - /** For EVM cross-chain recovery */ - isEvmBasedCrossChainRecovery: optional(t.boolean), /** Recipients of the transaction */ - recipients: optional(t.any), + recipients: optional(t.array(Recipient)), /** Custodian transaction ID */ custodianTransactionId: optional(t.string), + /** For EVM cross-chain recovery */ + isEvmBasedCrossChainRecovery: optional(t.boolean), + /** Wallet version (for EVM) */ + walletVersion: optional(t.number), + + // UTXO-specific fields + /** Public keys for multi-signature transactions (xpub triple: user, backup, bitgo) */ + pubs: optional(t.array(t.string)), + /** Cosigner public key (defaults to bitgo) */ + cosignerPub: optional(t.string), /** Signing step for MuSig2 */ signingStep: optional(t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')])), - /** Allow non-segwit signing without previous transaction */ + /** Allow non-segwit signing without previous transaction (deprecated) */ allowNonSegwitSigningWithoutPrevTx: optional(t.boolean), } as const; @@ -79,17 +158,53 @@ export const FullySignedTransactionResponse = t.type({ /** * Response for a half-signed account transaction + * + * Used by all account-based coins including: + * - Generic account coins: Tron, Algorand, Solana, etc. (use txHex/payload/txBase64) + * - EVM coins: Ethereum, Polygon, etc. (use txHex + EVM-specific fields) + * + * This type includes all possible fields as EVM responses are a superset of the base + * HalfSignedAccountTransaction interface. TypeScript's structural typing allows this + * since a superset is assignable to the base type. + * + * Reference: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:408-414 (base) + * Reference: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1105-1118 (EVM superset) */ export const HalfSignedAccountTransactionResponse = t.type({ halfSigned: t.partial({ + // Generic account-based coin fields + /** Transaction in hex format (used by most account coins) */ txHex: optional(t.string), + /** Transaction payload (used by some account coins) */ payload: optional(t.string), + /** Transaction in base64 format (used by some account coins) */ txBase64: optional(t.string), + + // Additional EVM-specific fields (superset) + /** Transaction recipients (EVM) */ + recipients: t.array(Recipient), + /** Expiration timestamp (EVM) */ + expiration: t.number, + /** Expire time timestamp (EVM) */ + expireTime: t.number, + /** Contract sequence ID (EVM) */ + contractSequenceId: t.number, + /** Sequence ID for replay protection (EVM) */ + sequenceId: t.number, + /** EIP1559 parameters (EVM) */ + eip1559: EIP1559, + /** Hop transaction data (EVM) */ + hopTransaction: HopTransaction, + /** Custodian transaction ID (EVM) */ + custodianTransactionId: t.string, + /** Whether this is a batch transaction (EVM) */ + isBatch: t.boolean, }), }); /** - * Response for a half-signed UTXO transaction + * Response for a half-signed UTXO transaction (Bitcoin, Litecoin, etc.) + * Reference: modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts:404-406 */ export const HalfSignedUtxoTransactionResponse = t.type({ txHex: t.string, @@ -107,6 +222,13 @@ export const SignedTransactionRequestResponse = t.type({ * * Uses TxRequestResponse (TransactionRequest) from @bitgo/public-types for TSS transaction requests * (supports both Lite and Full versions) + * + * Response types match the SDK's SignedTransaction union: + * - FullySignedTransactionResponse: Fully signed transaction with txHex + * - HalfSignedAccountTransactionResponse: Half-signed account-based coins (includes EVM, Algorand, Solana, Tron, etc.) + * - HalfSignedUtxoTransactionResponse: Half-signed UTXO coins (Bitcoin, Litecoin, etc.) + * - SignedTransactionRequestResponse: TSS transaction request with txRequestId + * - TxRequestResponse: Full TSS transaction request (Lite/Full versions) */ export const CoinSignTxResponse = { /** Successfully signed transaction */ @@ -126,16 +248,27 @@ export const CoinSignTxResponse = { * * This endpoint signs a transaction for a specific coin type. * The request body is passed directly to coin.signTransaction() and varies by coin. - * Common fields include: + * + * Universal fields: * - txPrebuild: Contains transaction data like txHex or txBase64 * - prv: Private key for signing * - isLastSignature: Whether this is the last signature in a multi-sig tx - * - gasLimit: Gas limit for ETH transactions - * - gasPrice: Gas price for ETH transactions + * + * EVM-specific fields (Ethereum, Polygon, etc.): + * - gasLimit: Gas limit for transactions + * - gasPrice: Gas price for transactions * - expireTime: Transaction expiration time - * - sequenceId: Sequence ID for transactions - * - pubKeys: Public keys for multi-signature transactions - * - isEvmBasedCrossChainRecovery: For EVM cross-chain recovery + * - sequenceId: Sequence ID for replay protection + * - recipients: Transaction recipients + * - custodianTransactionId: Custodian transaction ID + * - walletVersion: Wallet version + * - isEvmBasedCrossChainRecovery: For cross-chain recovery + * + * UTXO-specific fields (Bitcoin, Litecoin, etc.): + * - pubs: Public keys array (xpub triple: user, backup, bitgo) + * - cosignerPub: Cosigner's public key (defaults to bitgo) + * - signingStep: MuSig2 signing step + * - allowNonSegwitSigningWithoutPrevTx: Legacy parameter * * @tag express * @operationId express.v2.coin.signtx diff --git a/modules/express/test/unit/typedRoutes/coinSignTx.ts b/modules/express/test/unit/typedRoutes/coinSignTx.ts index ce77afa031..e03e679d53 100644 --- a/modules/express/test/unit/typedRoutes/coinSignTx.ts +++ b/modules/express/test/unit/typedRoutes/coinSignTx.ts @@ -742,7 +742,7 @@ describe('CoinSignTx codec tests', function () { gasPrice: '20000000000', expireTime: 1633046400000, sequenceId: 42, - pubKeys: [ + pubs: [ '03a247b2c6826c3f833c6e164a3be1b124bf5f6de0d837a143a4d81e427a43a26f', '02d3a8e9a42b89168a54f09476d40b8d60f5d553f6dcc8e5bf3e8b2733cff25c92', ], @@ -764,7 +764,7 @@ describe('CoinSignTx codec tests', function () { assert.strictEqual(decoded.gasPrice, validBody.gasPrice); assert.strictEqual(decoded.expireTime, validBody.expireTime); assert.strictEqual(decoded.sequenceId, validBody.sequenceId); - assert.deepStrictEqual(decoded.pubKeys, validBody.pubKeys); + assert.deepStrictEqual(decoded.pubs, validBody.pubs); assert.strictEqual(decoded.isEvmBasedCrossChainRecovery, validBody.isEvmBasedCrossChainRecovery); assert.deepStrictEqual(decoded.recipients, validBody.recipients); assert.strictEqual(decoded.custodianTransactionId, validBody.custodianTransactionId); @@ -789,6 +789,55 @@ describe('CoinSignTx codec tests', function () { assert.strictEqual(decoded.gasPrice, validBody.gasPrice); }); + it('should validate body with UTXO-specific fields (pubs and cosignerPub)', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + pubs: [ + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet9', + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet0', + ], + cosignerPub: + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet0', + isLastSignature: false, + signingStep: 'signerNonce', + }; + + const decoded = assertDecode(t.partial(CoinSignTxBody), validBody); + assert.strictEqual(decoded.prv, validBody.prv); + assert.deepStrictEqual(decoded.pubs, validBody.pubs); + assert.strictEqual(decoded.cosignerPub, validBody.cosignerPub); + assert.strictEqual(decoded.isLastSignature, validBody.isLastSignature); + assert.strictEqual(decoded.signingStep, validBody.signingStep); + }); + + it('should validate body with EVM-specific fields (walletVersion)', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: + '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a7612020000000000000000000000002c8f0c8aad01af9f2c6ba6f4edbf6f46000a0eedc080a0', + }, + isLastSignature: false, + walletVersion: 3, + gasLimit: 21000, + gasPrice: '20000000000', + sequenceId: 5, + expireTime: 1700000000, + }; + + const decoded = assertDecode(t.partial(CoinSignTxBody), validBody); + assert.strictEqual(decoded.walletVersion, validBody.walletVersion); + assert.strictEqual(decoded.gasLimit, validBody.gasLimit); + assert.strictEqual(decoded.gasPrice, validBody.gasPrice); + assert.strictEqual(decoded.sequenceId, validBody.sequenceId); + assert.strictEqual(decoded.expireTime, validBody.expireTime); + }); + it('should reject body with invalid field types', function () { const invalidBody = { prv: 123, // number instead of string @@ -825,7 +874,7 @@ describe('CoinSignTx codec tests', function () { }); describe('HalfSignedAccountTransactionResponse', function () { - it('should validate response with all halfSigned fields', function () { + it('should validate response with generic account coin fields', function () { const validResponse = { halfSigned: { txHex: @@ -842,6 +891,61 @@ describe('CoinSignTx codec tests', function () { assert.strictEqual(decoded.halfSigned.txBase64, validResponse.halfSigned.txBase64); }); + it('should validate response with EVM-specific fields', function () { + const validResponse = { + halfSigned: { + txHex: '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a761202', + recipients: [ + { address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', amount: 1000000 }, + { address: '0x9b9f8e3a7c5b9e1c4a7d6e5f8a9b0c1d2e3f4a5b', amount: 500000 }, + ], + eip1559: { + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1500000000', + }, + expiration: 1700000000, + expireTime: 1700000000, + contractSequenceId: 42, + sequenceId: 5, + hopTransaction: { + txHex: '0x123456', + userReqSig: '0xabcdef', + gasPriceMax: 3000000000, + gasLimit: 21000, + }, + custodianTransactionId: 'custodian-tx-12345', + isBatch: false, + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.txHex, validResponse.halfSigned.txHex); + assert.deepStrictEqual(decoded.halfSigned.recipients, validResponse.halfSigned.recipients); + assert.deepStrictEqual(decoded.halfSigned.eip1559, validResponse.halfSigned.eip1559); + assert.strictEqual(decoded.halfSigned.expiration, validResponse.halfSigned.expiration); + 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.custodianTransactionId, validResponse.halfSigned.custodianTransactionId); + assert.strictEqual(decoded.halfSigned.isBatch, validResponse.halfSigned.isBatch); + }); + + it('should validate response with mixed generic and EVM fields', function () { + const validResponse = { + halfSigned: { + txHex: '0x02f87301808459682f008459682f0e8252089439c0f2000e39186af4b78b554eb96a2ea8dc5c3680a46a761202', + recipients: [{ address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', amount: 1000000 }], + contractSequenceId: 10, + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.txHex, validResponse.halfSigned.txHex); + assert.deepStrictEqual(decoded.halfSigned.recipients, validResponse.halfSigned.recipients); + assert.strictEqual(decoded.halfSigned.contractSequenceId, validResponse.halfSigned.contractSequenceId); + }); + it('should validate response with empty halfSigned', function () { const validResponse = { halfSigned: {},