Skip to content

Commit d79df81

Browse files
committed
fix(express): fixed type codec for coinSign
Ticket: WP-6649
1 parent 6bf1d6f commit d79df81

File tree

2 files changed

+92
-66
lines changed

2 files changed

+92
-66
lines changed

modules/express/src/typedRoutes/api/v2/coinSign.ts

Lines changed: 63 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import * as t from 'io-ts';
22
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
33
import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types';
44
import { BitgoExpressError } from '../../schemas/error';
5+
import {
6+
TransactionPrebuild,
7+
Recipient,
8+
FullySignedTransactionResponse,
9+
HalfSignedAccountTransactionResponse,
10+
HalfSignedUtxoTransactionResponse,
11+
SignedTransactionRequestResponse,
12+
} from './coinSignTx';
513

614
/**
715
* Request parameters for signing a transaction (external signer mode)
@@ -13,33 +21,21 @@ export const CoinSignParams = {
1321

1422
/**
1523
* Transaction prebuild information for external signing
16-
* Requires walletId to retrieve encrypted private key from filesystem
24+
*
25+
* Same as TransactionPrebuild from coinSignTx, but with walletId as REQUIRED field.
26+
* The walletId is required for retrieving the encrypted private key from the filesystem.
27+
*
28+
* This is enforced by the handler at runtime (clientRoutes.ts:513-517).
29+
*
30+
* Reference: modules/express/src/typedRoutes/api/v2/coinSignTx.ts:102-191 (TransactionPrebuild)
31+
* Handler validation: modules/express/src/clientRoutes.ts:513-517 (handleV2Sign)
1732
*/
1833
export const TransactionPrebuildForExternalSigning = t.intersection([
1934
t.type({
20-
/** Wallet ID - required for retrieving encrypted private key */
35+
/** Wallet ID - REQUIRED for retrieving encrypted private key from filesystem */
2136
walletId: t.string,
2237
}),
23-
t.partial({
24-
/** Transaction in hex format */
25-
txHex: t.string,
26-
/** Transaction in base64 format (for some coins) */
27-
txBase64: t.string,
28-
/** Transaction in JSON format (for some coins) */
29-
txInfo: t.any,
30-
/** Next contract sequence ID (for ETH) */
31-
nextContractSequenceId: t.number,
32-
/** Whether this is a batch transaction (for ETH) */
33-
isBatch: t.boolean,
34-
/** EIP1559 transaction parameters (for ETH) */
35-
eip1559: t.any,
36-
/** Hop transaction data (for ETH) */
37-
hopTransaction: t.any,
38-
/** Backup key nonce (for ETH) */
39-
backupKeyNonce: t.any,
40-
/** Recipients of the transaction */
41-
recipients: t.any,
42-
}),
38+
TransactionPrebuild,
4339
]);
4440

4541
/**
@@ -48,17 +44,31 @@ export const TransactionPrebuildForExternalSigning = t.intersection([
4844
* This route is used when BitGo Express is configured with external signing.
4945
* The private key is retrieved from the filesystem and decrypted using
5046
* a wallet passphrase stored in the environment variable WALLET_{walletId}_PASSPHRASE.
47+
*
48+
* Fields are similar to CoinSignTxBody except:
49+
* - NO `prv` field (added automatically by handler from filesystem)
50+
* - HAS `derivationSeed` field (unique to external signing)
51+
* - `txPrebuild` has required `walletId` field
52+
*
53+
* Reference: modules/express/src/typedRoutes/api/v2/coinSignTx.ts:250-293 (CoinSignTxBody)
54+
* Handler: modules/express/src/clientRoutes.ts:512-539 (handleV2Sign)
5155
*/
5256
export const CoinSignBody = {
53-
/** Transaction prebuild data - must contain walletId */
57+
/** Transaction prebuild data - must contain walletId (REQUIRED) */
5458
txPrebuild: TransactionPrebuildForExternalSigning,
59+
5560
/**
5661
* Derivation seed for deriving a child key from the main private key.
5762
* If provided, the key will be derived using coin.deriveKeyWithSeed()
63+
* UNIQUE TO EXTERNAL SIGNING - not present in CoinSignTxBody
5864
*/
5965
derivationSeed: optional(t.string),
66+
67+
// ============ Universal fields ============
6068
/** Whether this is the last signature in a multi-sig tx */
6169
isLastSignature: optional(t.boolean),
70+
71+
// ============ EVM-specific fields ============
6272
/** Gas limit for ETH transactions */
6373
gasLimit: optional(t.union([t.string, t.number])),
6474
/** Gas price for ETH transactions */
@@ -67,52 +77,46 @@ export const CoinSignBody = {
6777
expireTime: optional(t.number),
6878
/** Sequence ID for transactions */
6979
sequenceId: optional(t.number),
70-
/** Public keys for multi-signature transactions */
71-
pubKeys: optional(t.array(t.string)),
72-
/** For EVM cross-chain recovery */
73-
isEvmBasedCrossChainRecovery: optional(t.boolean),
7480
/** Recipients of the transaction */
75-
recipients: optional(t.any),
81+
recipients: optional(t.array(Recipient)),
7682
/** Custodian transaction ID */
7783
custodianTransactionId: optional(t.string),
84+
/** For EVM cross-chain recovery */
85+
isEvmBasedCrossChainRecovery: optional(t.boolean),
86+
/** Wallet version (for EVM) */
87+
walletVersion: optional(t.number),
88+
/** Signing key nonce for EVM final signing */
89+
signingKeyNonce: optional(t.number),
90+
/** Wallet contract address for EVM final signing */
91+
walletContractAddress: optional(t.string),
92+
93+
// ============ UTXO-specific fields ============
94+
/** Public keys for multi-signature transactions (xpub triple: user, backup, bitgo) */
95+
pubs: optional(t.array(t.string)),
96+
/** Cosigner public key (defaults to bitgo) */
97+
cosignerPub: optional(t.string),
7898
/** Signing step for MuSig2 */
7999
signingStep: optional(t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')])),
80-
/** Allow non-segwit signing without previous transaction */
100+
/** Allow non-segwit signing without previous transaction (deprecated) */
81101
allowNonSegwitSigningWithoutPrevTx: optional(t.boolean),
82-
} as const;
83102

84-
/**
85-
* Response for a fully signed transaction
86-
*/
87-
export const FullySignedTransactionResponse = t.type({
88-
/** Transaction in hex format */
89-
txHex: t.string,
90-
});
91-
92-
/**
93-
* Response for a half-signed account transaction
94-
*/
95-
export const HalfSignedAccountTransactionResponse = t.partial({
96-
halfSigned: t.partial({
97-
txHex: t.string,
98-
payload: t.string,
99-
txBase64: t.string,
100-
}),
101-
});
102-
103-
/**
104-
* Response for a half-signed UTXO transaction
105-
*/
106-
export const HalfSignedUtxoTransactionResponse = t.type({
107-
txHex: t.string,
108-
});
103+
// ============ Solana-specific fields ============
104+
/** Public keys for Solana transactions */
105+
pubKeys: optional(t.array(t.string)),
106+
} as const;
109107

110108
/**
111-
* Response for a transaction request
109+
* Response codecs are imported from coinSignTx.ts since both endpoints call the same
110+
* coin.signTransaction() method and return identical response formats:
111+
*
112+
* - FullySignedTransactionResponse: For fully signed transactions (all signatures collected)
113+
* - HalfSignedAccountTransactionResponse: For half-signed account-based transactions (EVM, Algorand, etc.)
114+
* - HalfSignedUtxoTransactionResponse: For half-signed UTXO transactions (BTC, LTC, etc.)
115+
* - SignedTransactionRequestResponse: For TSS transaction requests
116+
* - TxRequestResponse: For TSS transaction requests (from @bitgo/public-types)
117+
*
118+
* Reference: modules/express/src/typedRoutes/api/v2/coinSignTx.ts:267-418 (Response codecs)
112119
*/
113-
export const SignedTransactionRequestResponse = t.type({
114-
txRequestId: t.string,
115-
});
116120

117121
/**
118122
* Response for signing a transaction in external signer mode

modules/express/test/unit/typedRoutes/coinSign.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
CoinSignParams,
55
TransactionPrebuildForExternalSigning,
66
CoinSignBody,
7+
} from '../../../src/typedRoutes/api/v2/coinSign';
8+
import {
79
FullySignedTransactionResponse,
810
HalfSignedAccountTransactionResponse,
911
HalfSignedUtxoTransactionResponse,
1012
SignedTransactionRequestResponse,
11-
} from '../../../src/typedRoutes/api/v2/coinSign';
13+
} from '../../../src/typedRoutes/api/v2/coinSignTx';
1214
import { assertDecode } from './common';
1315
import 'should';
1416
import 'should-http';
@@ -656,15 +658,20 @@ describe('CoinSign codec tests (External Signer Mode)', function () {
656658
txInfo: { memo: 'test' },
657659
nextContractSequenceId: 123,
658660
isBatch: true,
659-
eip1559: { maxFeePerGas: '50000000000' },
660-
hopTransaction: { tx: 'hop-tx' },
661-
backupKeyNonce: { nonce: 1 },
661+
eip1559: {
662+
maxFeePerGas: '50000000000',
663+
maxPriorityFeePerGas: '1500000000',
664+
},
665+
hopTransaction: '0x123456abcdef', // String format (valid alternative to full HopTransaction object)
666+
backupKeyNonce: 42, // Number (valid - can also be string)
662667
recipients: [{ address: '0x123', amount: 1000 }],
663668
};
664669
const decoded = assertDecode(TransactionPrebuildForExternalSigning, validPrebuild);
665670
assert.strictEqual(decoded.walletId, walletId);
666671
assert.strictEqual(decoded.txHex, '0100000001...');
667672
assert.strictEqual(decoded.isBatch, true);
673+
assert.strictEqual(decoded.backupKeyNonce, 42);
674+
assert.strictEqual(decoded.hopTransaction, '0x123456abcdef');
668675
});
669676

670677
it('should fail validation when walletId is missing', function () {
@@ -708,12 +715,23 @@ describe('CoinSign codec tests (External Signer Mode)', function () {
708715
custodianTransactionId: 'cust-123',
709716
signingStep: 'signerNonce',
710717
allowNonSegwitSigningWithoutPrevTx: true,
718+
// New fields added from coinSignTx
719+
walletVersion: 3,
720+
signingKeyNonce: 5,
721+
walletContractAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
722+
pubs: ['xpub1...', 'xpub2...', 'xpub3...'],
723+
cosignerPub: 'xpub_cosigner...',
711724
};
712725
const decoded = assertDecode(t.type(CoinSignBody), validBody);
713726
assert.strictEqual(decoded.txPrebuild.walletId, walletId);
714727
assert.strictEqual(decoded.derivationSeed, 'test-seed');
715728
assert.strictEqual(decoded.isLastSignature, true);
716729
assert.strictEqual(decoded.gasLimit, 21000);
730+
assert.strictEqual(decoded.walletVersion, 3);
731+
assert.strictEqual(decoded.signingKeyNonce, 5);
732+
assert.strictEqual(decoded.walletContractAddress, '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb');
733+
assert.deepStrictEqual(decoded.pubs, ['xpub1...', 'xpub2...', 'xpub3...']);
734+
assert.strictEqual(decoded.cosignerPub, 'xpub_cosigner...');
717735
});
718736

719737
it('should validate body with gasLimit and gasPrice as different types', function () {
@@ -788,10 +806,14 @@ describe('CoinSign codec tests (External Signer Mode)', function () {
788806
assert.deepStrictEqual(decoded.halfSigned, {});
789807
});
790808

791-
it('should validate response without halfSigned (optional)', function () {
792-
const validResponse = {};
809+
it('should validate response with minimal halfSigned', function () {
810+
const validResponse = {
811+
halfSigned: {
812+
txHex: '0x123456',
813+
},
814+
};
793815
const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse);
794-
assert.strictEqual(decoded.halfSigned, undefined);
816+
assert.strictEqual(decoded.halfSigned.txHex, '0x123456');
795817
});
796818
});
797819

0 commit comments

Comments
 (0)