Skip to content

Commit 6b45372

Browse files
Merge pull request #8664 from BitGo/zahinmohammad/coinflp-116-tezos-xtzjs-test-is-flaky-in-ci
fix(sdk-coin-xtz): preserve txid when re-parsing signed origination
2 parents e385447 + 0bcfc61 commit 6b45372

3 files changed

Lines changed: 64 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: 35 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,32 @@ 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+
// If `serialized` really is `forge(ops) || signature`, stripping the trailing 64
26+
// bytes gives the exact forge bytes and forge(parse(...)) reproduces them. If
27+
// `serialized` was unsigned, stripping cuts into the operation contents: parse
28+
// either throws or recovers a truncated parse whose forge does not match. So a
29+
// clean round-trip is what proves the trailing bytes were a signature.
30+
const operationBytes = serialized.slice(0, -SIGNATURE_HEX_LENGTH);
31+
try {
32+
const parsed = await localForger.parse(operationBytes);
33+
const roundTrip = await localForger.forge(parsed);
34+
if (roundTrip !== operationBytes) {
35+
return undefined;
36+
}
37+
return { parsed, transactionId: await Utils.calculateTransactionId(serialized) };
38+
} catch (_) {
39+
return undefined;
40+
}
41+
}
42+
2343
/**
2444
* Tezos transaction model.
2545
*/
@@ -49,22 +69,16 @@ export class Transaction extends BaseTransaction {
4969
*/
5070
async initFromSerializedTransaction(serializedTransaction: string): Promise<void> {
5171
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-
}
72+
// Only signed inputs have a transaction id (it is the hash of the signed bytes);
73+
// unsigned inputs leave it unset and let the caller populate it after signing.
74+
const signed = await tryParseSigned(serializedTransaction);
75+
if (signed) {
6376
// 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);
77+
await this.initFromParsedTransaction(signed.parsed, signed.transactionId);
78+
return;
6779
}
80+
const parsed = await localForger.parse(serializedTransaction);
81+
await this.initFromParsedTransaction(parsed);
6882
}
6983

7084
/**

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)