Skip to content

Commit e2f30fc

Browse files
authored
Merge pull request #9530 from BitGo/SI-1293-preserve-plutus-fields-pledged-ada-tx
fix(sdk-coin-ada): preserve plutus fields on pledged cardano tx
2 parents 58b203b + 5a4bf0c commit e2f30fc

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,40 @@ export class Transaction extends BaseTransaction {
470470
return this._pledgeDetails;
471471
}
472472

473+
/**
474+
* Detects whether the parsed transaction carries any Plutus-related content
475+
* (script data hash, collateral, reference inputs, plutus datums/scripts/redeemers).
476+
* Used to route prebuilt Plutus txs (e.g. RealFi) through the sign-only passthrough
477+
* path, which preserves fields the generic builder does not reconstruct.
478+
*/
479+
hasPlutusData(): boolean {
480+
if (!this._transaction) {
481+
return false;
482+
}
483+
const body = this._transaction.body();
484+
const witnessSet = this._transaction.witness_set();
485+
if (
486+
body.script_data_hash() !== undefined ||
487+
body.collateral() !== undefined ||
488+
body.collateral_return() !== undefined ||
489+
body.total_collateral() !== undefined ||
490+
body.reference_inputs() !== undefined ||
491+
witnessSet.plutus_scripts() !== undefined ||
492+
witnessSet.plutus_data() !== undefined ||
493+
witnessSet.redeemers() !== undefined
494+
) {
495+
return true;
496+
}
497+
const outputs = body.outputs();
498+
for (let i = 0; i < outputs.len(); i++) {
499+
const output = outputs.get(i);
500+
if (output.has_plutus_data() || output.has_script_ref()) {
501+
return true;
502+
}
503+
}
504+
return false;
505+
}
506+
473507
/**
474508
* Get transaction fee
475509
*/

modules/sdk-coin-ada/src/lib/transactionBuilder.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,69 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
576576
return this.transaction;
577577
}
578578

579+
/**
580+
* Sign-only path for prebuilt Plutus transactions (e.g. RealFi order CBOR from WP).
581+
*
582+
* Does not rebuild body or recompute fees/outputs — reuses the parsed body and
583+
* auxiliary data verbatim so Plutus fields survive. Only regenerates vkeys;
584+
* other witness content is copied through as-is.
585+
*/
586+
private buildPlutusPassthrough(): Transaction {
587+
const originalTx = this._transaction.transaction;
588+
const body = originalTx.body();
589+
const originalWitnessSet = originalTx.witness_set();
590+
const txHash = CardanoWasm.hash_transaction(body);
591+
592+
const vkeyWitnesses = CardanoWasm.Vkeywitnesses.new();
593+
this._signers.forEach((keyPair) => {
594+
const prv = keyPair.getKeys().prv as string;
595+
const vkeyWitness = CardanoWasm.make_vkey_witness(
596+
txHash,
597+
CardanoWasm.PrivateKey.from_normal_bytes(Buffer.from(prv, 'hex'))
598+
);
599+
vkeyWitnesses.add(vkeyWitness);
600+
});
601+
602+
this._transaction.signature.length = 0;
603+
this.getAllSignatures().forEach((signature) => {
604+
const vkey = CardanoWasm.Vkey.new(CardanoWasm.PublicKey.from_bytes(Buffer.from(signature.publicKey.pub, 'hex')));
605+
const ed255Sig = CardanoWasm.Ed25519Signature.from_bytes(signature.signature);
606+
vkeyWitnesses.add(CardanoWasm.Vkeywitness.new(vkey, ed255Sig));
607+
this._transaction.signature.push(signature.signature.toString('hex'));
608+
});
609+
610+
const witnessSet = CardanoWasm.TransactionWitnessSet.new();
611+
witnessSet.set_vkeys(vkeyWitnesses);
612+
if (originalWitnessSet.native_scripts() !== undefined) {
613+
witnessSet.set_native_scripts(originalWitnessSet.native_scripts()!);
614+
}
615+
if (originalWitnessSet.bootstraps() !== undefined) {
616+
witnessSet.set_bootstraps(originalWitnessSet.bootstraps()!);
617+
}
618+
if (originalWitnessSet.plutus_scripts() !== undefined) {
619+
witnessSet.set_plutus_scripts(originalWitnessSet.plutus_scripts()!);
620+
}
621+
if (originalWitnessSet.plutus_data() !== undefined) {
622+
witnessSet.set_plutus_data(originalWitnessSet.plutus_data()!);
623+
}
624+
if (originalWitnessSet.redeemers() !== undefined) {
625+
witnessSet.set_redeemers(originalWitnessSet.redeemers()!);
626+
}
627+
628+
// Preserve metadata when present — omitting it leaves auxiliary_data_hash dangling.
629+
const auxiliaryData = originalTx.auxiliary_data();
630+
this._transaction.transaction =
631+
auxiliaryData !== undefined
632+
? CardanoWasm.Transaction.new(body, witnessSet, auxiliaryData)
633+
: CardanoWasm.Transaction.new(body, witnessSet);
634+
return this.transaction;
635+
}
636+
579637
/** @inheritdoc */
580638
protected async buildImplementation(): Promise<Transaction> {
639+
if (this._transaction.hasPlutusData()) {
640+
return this.buildPlutusPassthrough();
641+
}
581642
if (this._explicitOutputs.length > 0) {
582643
return this.processExplicitOutputsBuild();
583644
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
2+
import should from 'should';
3+
import { coins } from '@bitgo/statics';
4+
import { KeyPair, Transaction, TransactionBuilderFactory } from '../../src';
5+
import { privateKeys, rawTx } from '../resources';
6+
7+
const TEST_ADDRESS = 'addr_test1vr8rakm66rcfv4fcxqykg5lf0yv7lsyk9mvapx369jpvtcgfcuk7f';
8+
9+
/**
10+
* Builds a synthetic Plutus transaction analogous to CBOR WP would hand BitGoJS
11+
* for RealFi pledge (parse -> sign -> re-serialize) flows.
12+
*/
13+
function buildPlutusFixtureHex(includeAuxiliaryData = false): string {
14+
const addr = CardanoWasm.Address.from_bech32(TEST_ADDRESS);
15+
16+
const inputs = CardanoWasm.TransactionInputs.new();
17+
inputs.add(CardanoWasm.TransactionInput.new(CardanoWasm.TransactionHash.from_bytes(Buffer.alloc(32, 1)), 0));
18+
19+
const datum = CardanoWasm.PlutusData.new_bytes(Buffer.from('deadbeef', 'hex'));
20+
21+
const outputs = CardanoWasm.TransactionOutputs.new();
22+
const outputBuilder = CardanoWasm.TransactionOutputBuilder.new().with_address(addr).with_plutus_data(datum);
23+
const mainOutput = outputBuilder.next().with_coin(CardanoWasm.BigNum.from_str('2000000')).build();
24+
outputs.add(mainOutput);
25+
26+
const body = CardanoWasm.TransactionBody.new_tx_body(inputs, outputs, CardanoWasm.BigNum.from_str('200000'));
27+
28+
const collateral = CardanoWasm.TransactionInputs.new();
29+
collateral.add(CardanoWasm.TransactionInput.new(CardanoWasm.TransactionHash.from_bytes(Buffer.alloc(32, 2)), 0));
30+
body.set_collateral(collateral);
31+
32+
const collateralReturn = CardanoWasm.TransactionOutput.new(
33+
addr,
34+
CardanoWasm.Value.new(CardanoWasm.BigNum.from_str('1000000'))
35+
);
36+
body.set_collateral_return(collateralReturn);
37+
body.set_total_collateral(CardanoWasm.BigNum.from_str('1000000'));
38+
39+
const referenceInputs = CardanoWasm.TransactionInputs.new();
40+
referenceInputs.add(CardanoWasm.TransactionInput.new(CardanoWasm.TransactionHash.from_bytes(Buffer.alloc(32, 3)), 0));
41+
body.set_reference_inputs(referenceInputs);
42+
body.set_script_data_hash(CardanoWasm.ScriptDataHash.from_bytes(Buffer.alloc(32, 4)));
43+
44+
const witnessSet = CardanoWasm.TransactionWitnessSet.new();
45+
46+
const plutusScripts = CardanoWasm.PlutusScripts.new();
47+
plutusScripts.add(CardanoWasm.PlutusScript.new(Buffer.from('510100003222253330033371e00c', 'hex')));
48+
witnessSet.set_plutus_scripts(plutusScripts);
49+
50+
const plutusDataList = CardanoWasm.PlutusList.new();
51+
plutusDataList.add(datum);
52+
witnessSet.set_plutus_data(plutusDataList);
53+
54+
const redeemers = CardanoWasm.Redeemers.new();
55+
redeemers.add(
56+
CardanoWasm.Redeemer.new(
57+
CardanoWasm.RedeemerTag.new_spend(),
58+
CardanoWasm.BigNum.from_str('0'),
59+
datum,
60+
CardanoWasm.ExUnits.new(CardanoWasm.BigNum.from_str('1000000'), CardanoWasm.BigNum.from_str('500000'))
61+
)
62+
);
63+
witnessSet.set_redeemers(redeemers);
64+
65+
let auxiliaryData: CardanoWasm.AuxiliaryData | undefined;
66+
if (includeAuxiliaryData) {
67+
const metadata = CardanoWasm.GeneralTransactionMetadata.new();
68+
metadata.insert(CardanoWasm.BigNum.from_str('674'), CardanoWasm.TransactionMetadatum.new_text('realfi-test'));
69+
auxiliaryData = CardanoWasm.AuxiliaryData.new();
70+
auxiliaryData.set_metadata(metadata);
71+
body.set_auxiliary_data_hash(CardanoWasm.hash_auxiliary_data(auxiliaryData));
72+
}
73+
74+
const tx =
75+
auxiliaryData !== undefined
76+
? CardanoWasm.Transaction.new(body, witnessSet, auxiliaryData)
77+
: CardanoWasm.Transaction.new(body, witnessSet);
78+
return Buffer.from(tx.to_bytes()).toString('hex');
79+
}
80+
81+
describe('ADA Plutus Passthrough Builder', () => {
82+
const plutusFixtureHex = buildPlutusFixtureHex();
83+
84+
it('should detect plutus data on the parsed transaction', () => {
85+
const tx = new Transaction(coins.get('tada'));
86+
tx.fromRawTransaction(plutusFixtureHex);
87+
tx.hasPlutusData().should.be.true();
88+
89+
const plainTx = new Transaction(coins.get('tada'));
90+
plainTx.fromRawTransaction(rawTx.unsignedNewPledgeTx);
91+
plainTx.hasPlutusData().should.be.false();
92+
});
93+
94+
it('should round-trip a plutus transaction byte-for-byte with no new signature', async () => {
95+
const factory = new TransactionBuilderFactory(coins.get('tada'));
96+
const txBuilder = factory.from(plutusFixtureHex);
97+
const tx = (await txBuilder.build()) as Transaction;
98+
tx.toBroadcastFormat().should.equal(plutusFixtureHex);
99+
});
100+
101+
it('should preserve plutus fields and produce a valid vkey for the body hash', async () => {
102+
const factory = new TransactionBuilderFactory(coins.get('tada'));
103+
const txBuilder = factory.from(plutusFixtureHex);
104+
txBuilder.sign({ key: privateKeys.prvKey4 });
105+
const tx = (await txBuilder.build()) as Transaction;
106+
const broadcastHex = tx.toBroadcastFormat();
107+
broadcastHex.should.not.equal(plutusFixtureHex);
108+
109+
const reparsed = CardanoWasm.Transaction.from_bytes(Buffer.from(broadcastHex, 'hex'));
110+
const body = reparsed.body();
111+
const witnessSet = reparsed.witness_set();
112+
113+
should.exist(body.collateral());
114+
should.exist(body.collateral_return());
115+
should.exist(body.total_collateral());
116+
should.exist(body.reference_inputs());
117+
should.exist(body.script_data_hash());
118+
body.outputs().get(0).has_plutus_data().should.be.true();
119+
120+
should.exist(witnessSet.plutus_scripts());
121+
should.exist(witnessSet.plutus_data());
122+
should.exist(witnessSet.redeemers());
123+
124+
const originalTx = CardanoWasm.Transaction.from_bytes(Buffer.from(plutusFixtureHex, 'hex'));
125+
body.collateral()!.to_bytes().should.deepEqual(originalTx.body().collateral()!.to_bytes());
126+
body.collateral_return()!.to_bytes().should.deepEqual(originalTx.body().collateral_return()!.to_bytes());
127+
body.total_collateral()!.to_bytes().should.deepEqual(originalTx.body().total_collateral()!.to_bytes());
128+
body.reference_inputs()!.to_bytes().should.deepEqual(originalTx.body().reference_inputs()!.to_bytes());
129+
body.script_data_hash()!.to_bytes().should.deepEqual(originalTx.body().script_data_hash()!.to_bytes());
130+
witnessSet.plutus_scripts()!.to_bytes().should.deepEqual(originalTx.witness_set().plutus_scripts()!.to_bytes());
131+
witnessSet.plutus_data()!.to_bytes().should.deepEqual(originalTx.witness_set().plutus_data()!.to_bytes());
132+
witnessSet.redeemers()!.to_bytes().should.deepEqual(originalTx.witness_set().redeemers()!.to_bytes());
133+
134+
const keyPair = new KeyPair({ prv: privateKeys.prvKey4 });
135+
const expected = CardanoWasm.make_vkey_witness(
136+
CardanoWasm.hash_transaction(body),
137+
CardanoWasm.PrivateKey.from_normal_bytes(Buffer.from(keyPair.getKeys().prv!, 'hex'))
138+
);
139+
const vkeys = witnessSet.vkeys();
140+
should.exist(vkeys);
141+
vkeys!.len().should.equal(1);
142+
vkeys!.get(0).vkey().public_key().to_hex().should.equal(keyPair.getKeys().pub);
143+
vkeys!.get(0).signature().to_hex().should.equal(expected.signature().to_hex());
144+
});
145+
146+
it('should preserve auxiliary data through the passthrough path', async () => {
147+
const fixtureWithAux = buildPlutusFixtureHex(true);
148+
const factory = new TransactionBuilderFactory(coins.get('tada'));
149+
const txBuilder = factory.from(fixtureWithAux);
150+
txBuilder.sign({ key: privateKeys.prvKey4 });
151+
const tx = (await txBuilder.build()) as Transaction;
152+
153+
const reparsed = CardanoWasm.Transaction.from_bytes(Buffer.from(tx.toBroadcastFormat(), 'hex'));
154+
const original = CardanoWasm.Transaction.from_bytes(Buffer.from(fixtureWithAux, 'hex'));
155+
should.exist(reparsed.auxiliary_data());
156+
reparsed.auxiliary_data()!.to_bytes().should.deepEqual(original.auxiliary_data()!.to_bytes());
157+
should.exist(reparsed.body().auxiliary_data_hash());
158+
reparsed
159+
.body()
160+
.auxiliary_data_hash()!
161+
.to_bytes()
162+
.should.deepEqual(original.body().auxiliary_data_hash()!.to_bytes());
163+
});
164+
});

0 commit comments

Comments
 (0)