Skip to content

Commit 1360c72

Browse files
fix(sdk-coin-xtz): preserve txid when re-parsing signed origination
The previous initFromSerializedTransaction relied on localForger.parse throwing on signed bytes (unsigned + 64-byte signature) to detect signed input — but the appended signature bytes are sometimes accidentally valid Michelson contents, so parse silently succeeds and the txid is left empty. wallet-platform then fails XTZ wallet creation with "unable to calculate the id of the deployment transaction" and "SendQueue validation failed: txid: Path \`txid\` is required" (COINFLP-116). Detect signed input by stripping the 64-byte suffix, parsing, and verifying the parsed result re-forges to those same bytes — a strict round-trip check rather than relying on parse to throw. Ticket: COINFLP-116 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e385447 commit 1360c72

2 files changed

Lines changed: 62 additions & 21 deletions

File tree

modules/sdk-coin-xtz/src/lib/transaction.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
BaseKey,
3-
BaseTransaction,
4-
InvalidTransactionError,
5-
ParseTransactionError,
6-
TransactionType,
7-
} from '@bitgo/sdk-core';
1+
import { BaseKey, BaseTransaction, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
82
import { BaseCoin as CoinConfig } from '@bitgo/statics';
93
import { localForger } from '@taquito/local-forging';
104
import { OpKind } from '@taquito/rpc';
@@ -49,22 +43,31 @@ export class Transaction extends BaseTransaction {
4943
*/
5044
async initFromSerializedTransaction(serializedTransaction: string): Promise<void> {
5145
this._encodedTransaction = serializedTransaction;
52-
try {
53-
const parsedTransaction = await localForger.parse(serializedTransaction);
54-
await this.initFromParsedTransaction(parsedTransaction);
55-
} catch (e) {
56-
// If it throws, it is possible the serialized transaction is signed, which is not supported
57-
// by local-forging. Try extracting the last 64 bytes and parse it again.
58-
const unsignedSerializedTransaction = serializedTransaction.slice(0, -128);
59-
const signature = serializedTransaction.slice(-128);
60-
if (Utils.isValidSignature(signature)) {
61-
throw new ParseTransactionError('Invalid transaction');
46+
// A signed Tezos transaction is the unsigned forged bytes with a 64-byte signature
47+
// appended (128 hex chars). Detect signed input by checking whether parsing the
48+
// bytes with the trailing signature stripped round-trips back to those same bytes;
49+
// a successful round-trip is what proves the suffix is a signature, not part of
50+
// the operation contents. We cannot rely on `localForger.parse` throwing on a
51+
// signed payload — the appended signature bytes are sometimes accidentally valid
52+
// operation contents and parse without error, which used to silently drop the
53+
// transaction id (COINFLP-116).
54+
if (serializedTransaction.length > 128) {
55+
const candidateUnsigned = serializedTransaction.slice(0, -128);
56+
try {
57+
const parsedTransaction = await localForger.parse(candidateUnsigned);
58+
const reForged = await localForger.forge(parsedTransaction);
59+
if (reForged === candidateUnsigned) {
60+
// TODO: encode the signature and save it in _signature
61+
const transactionId = await Utils.calculateTransactionId(serializedTransaction);
62+
await this.initFromParsedTransaction(parsedTransaction, transactionId);
63+
return;
64+
}
65+
} catch (_) {
66+
// fall through to the unsigned-parse path
6267
}
63-
// TODO: encode the signature and save it in _signature
64-
const parsedTransaction = await localForger.parse(unsignedSerializedTransaction);
65-
const transactionId = await Utils.calculateTransactionId(serializedTransaction);
66-
await this.initFromParsedTransaction(parsedTransaction, transactionId);
6768
}
69+
const parsedTransaction = await localForger.parse(serializedTransaction);
70+
await this.initFromParsedTransaction(parsedTransaction);
6871
}
6972

7073
/**

modules/sdk-coin-xtz/test/unit/transaction.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,44 @@ describe('Tezos transaction', function () {
4242
JSON.stringify(tx.toJson()).should.equal(JSON.stringify(parsedTransaction));
4343
tx.toBroadcastFormat().should.equal(signedSerializedOriginationTransaction);
4444
});
45+
46+
// COINFLP-116: when re-parsing a signed origination transaction whose appended
47+
// 64-byte signature happens to be parseable as additional Michelson contents,
48+
// localForger.parse does not throw, and the previous implementation skipped
49+
// the txid calculation — leaving `_id` empty and breaking address derivation.
50+
it('signed transaction whose signature suffix is accidentally parseable', async () => {
51+
// Deterministic 16-byte seed (uint32 BE = 174) selects a signing key whose
52+
// signature bytes form a valid Michelson tail when appended to the unsigned
53+
// transaction. This is the same shape of signed payload produced by
54+
// wallet-platform during XTZ wallet initialization.
55+
const seed = Buffer.alloc(16);
56+
seed.writeUInt32BE(174, 0);
57+
const signer = new XtzLib.KeyPair({ seed });
58+
59+
const signedTx = new XtzLib.Transaction(coins.get('txtz'));
60+
await signedTx.initFromSerializedTransaction(unsignedSerializedOriginationTransaction);
61+
await signedTx.sign(signer);
62+
const signedBytes = signedTx.toBroadcastFormat();
63+
const expectedTxId = signedTx.id;
64+
expectedTxId.should.match(/^o[a-zA-Z0-9]+$/, 'sanity: signed transaction must have an op-prefixed id');
65+
66+
// Re-parse the signed transaction the same way wallet-platform does after
67+
// signing (txBuilder.from(signedTx).build()).
68+
const reparsed = new XtzLib.Transaction(coins.get('txtz'));
69+
await reparsed.initFromSerializedTransaction(signedBytes);
70+
71+
reparsed.id.should.equal(
72+
expectedTxId,
73+
'transaction id must round-trip through serialization regardless of signature suffix bytes'
74+
);
75+
// The originated KT1 address is derived from the txid; if the id is empty,
76+
// the output address is empty too.
77+
reparsed.outputs.length.should.equal(1);
78+
reparsed.outputs[0].address.should.startWith(
79+
'KT1',
80+
'originated address must be derivable when re-parsing a signed transaction'
81+
);
82+
});
4583
});
4684

4785
describe('should sign', () => {

0 commit comments

Comments
 (0)