Skip to content

Commit 263bf39

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 263bf39

3 files changed

Lines changed: 57 additions & 21 deletions

File tree

commitlint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ module.exports = {
7373
'WCN-',
7474
'WCI-',
7575
'COIN-',
76+
'COINFLP-',
7677
'FIAT-',
7778
'ME-',
7879
'ANT-',

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

Lines changed: 28 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';
@@ -20,6 +14,27 @@ import {
2014
} from './multisigUtils';
2115
import * as Utils from './utils';
2216

17+
const SIGNATURE_HEX_LENGTH = 128;
18+
19+
async function tryParseSigned(
20+
serialized: string
21+
): Promise<{ parsed: ParsedTransaction; transactionId: string } | undefined> {
22+
if (serialized.length <= SIGNATURE_HEX_LENGTH) {
23+
return undefined;
24+
}
25+
const operationBytes = serialized.slice(0, -SIGNATURE_HEX_LENGTH);
26+
try {
27+
const parsed = await localForger.parse(operationBytes);
28+
const roundTrip = await localForger.forge(parsed);
29+
if (roundTrip !== operationBytes) {
30+
return undefined;
31+
}
32+
return { parsed, transactionId: await Utils.calculateTransactionId(serialized) };
33+
} catch (_) {
34+
return undefined;
35+
}
36+
}
37+
2338
/**
2439
* Tezos transaction model.
2540
*/
@@ -49,22 +64,14 @@ export class Transaction extends BaseTransaction {
4964
*/
5065
async initFromSerializedTransaction(serializedTransaction: string): Promise<void> {
5166
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');
62-
}
67+
const signed = await tryParseSigned(serializedTransaction);
68+
if (signed) {
6369
// 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);
70+
await this.initFromParsedTransaction(signed.parsed, signed.transactionId);
71+
return;
6772
}
73+
const parsed = await localForger.parse(serializedTransaction);
74+
await this.initFromParsedTransaction(parsed);
6875
}
6976

7077
/**

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import {
99
import { OperationContents } from '@taquito/rpc';
1010
import { XtzLib } from '../../src';
1111

12+
// Signing the fixture origination with this seed produces a 64-byte signature
13+
// whose bytes are coincidentally valid Michelson contents.
14+
function signerSeedProducingMichelsonShapedSignature(): Buffer {
15+
const seed = Buffer.alloc(16);
16+
seed.writeUInt32BE(174, 0);
17+
return seed;
18+
}
19+
1220
describe('Tezos transaction', function () {
1321
describe('should parse', () => {
1422
it('unsigned transaction', async () => {
@@ -42,6 +50,26 @@ describe('Tezos transaction', function () {
4250
JSON.stringify(tx.toJson()).should.equal(JSON.stringify(parsedTransaction));
4351
tx.toBroadcastFormat().should.equal(signedSerializedOriginationTransaction);
4452
});
53+
54+
it('signed transaction whose signature suffix forges as valid Michelson', async () => {
55+
const signerWithMichelsonShapedSignature = new XtzLib.KeyPair({
56+
seed: signerSeedProducingMichelsonShapedSignature(),
57+
});
58+
59+
const signedTx = new XtzLib.Transaction(coins.get('txtz'));
60+
await signedTx.initFromSerializedTransaction(unsignedSerializedOriginationTransaction);
61+
await signedTx.sign(signerWithMichelsonShapedSignature);
62+
const signedBytes = signedTx.toBroadcastFormat();
63+
const expectedTxId = signedTx.id;
64+
expectedTxId.should.match(/^o[a-zA-Z0-9]+$/);
65+
66+
const reparsed = new XtzLib.Transaction(coins.get('txtz'));
67+
await reparsed.initFromSerializedTransaction(signedBytes);
68+
69+
reparsed.id.should.equal(expectedTxId);
70+
reparsed.outputs.length.should.equal(1);
71+
reparsed.outputs[0].address.should.startWith('KT1');
72+
});
4573
});
4674

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

0 commit comments

Comments
 (0)