Skip to content

Commit b0fb0d3

Browse files
committed
feat(sdk-coin-ada): add explicit-output build for consolidation
Adds ExplicitOutput interface and explicitOutput() builder method to support single-asset consolidation and token withdrawal flows. Uses a two-pass fee-calculation approach (mirrors processTokenBuild) where outputs are pre-computed by the caller; only fee and fee-address change output are appended by the SDK. Requires sponsorshipInfo to be set. Ticket: CSHLD-641
1 parent 902654c commit b0fb0d3

4 files changed

Lines changed: 218 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Utils from './utils';
22

33
export { KeyPair } from './keyPair';
4-
export { Transaction } from './transaction';
4+
export { ExplicitOutput, Transaction } from './transaction';
55
export { TransactionBuilder } from './transactionBuilder';
66
export { TransferBuilder } from './transferBuilder';
77
export { TransactionBuilderFactory } from './transactionBuilderFactory';

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ export interface TransactionOutput {
2929
multiAssets?: CardanoWasm.MultiAsset | Asset;
3030
}
3131

32+
/**
33+
* A plain-object output descriptor used in single-asset consolidation mode.
34+
*/
35+
export interface ExplicitOutput {
36+
address: string;
37+
amount: string;
38+
/** Native tokens carried by this output (optional). */
39+
assets?: Asset[];
40+
}
41+
3242
export interface Witness {
3343
publicKey: string;
3444
signature: string;

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import {
1010
TransactionType,
1111
UtilsError,
1212
} from '@bitgo/sdk-core';
13-
import { Asset, Transaction, TransactionInput, TransactionOutput, Withdrawal, SponsorshipInfo } from './transaction';
13+
import {
14+
Asset,
15+
ExplicitOutput,
16+
SponsorshipInfo,
17+
Transaction,
18+
TransactionInput,
19+
TransactionOutput,
20+
Withdrawal,
21+
} from './transaction';
1422
import { KeyPair } from './keyPair';
1523
import util, { MIN_ADA_FOR_ONE_ASSET } from './utils';
1624
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
@@ -50,6 +58,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
5058
private _fee: BigNum;
5159
/** Flag indicating if this is a token transaction */
5260
private _isTokenTransaction = false;
61+
/**
62+
* Pre-computed outputs for single-asset consolidation / explicit-output builds.
63+
* When non-empty, build() uses processExplicitOutputsBuild: the SDK only
64+
* calculates fee and appends the fee-address change output (requires sponsorshipInfo).
65+
*/
66+
private _explicitOutputs: ExplicitOutput[] = [];
5367
/** Deep clone of _senderAssetList - for manipulating during two iterations
5468
* - one for calculating the fee
5569
* - one for the actual transaction build with the calculated fee
@@ -119,6 +133,17 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
119133
return this;
120134
}
121135

136+
/**
137+
* Adds a pre-computed output for single-asset consolidation / explicit-output builds.
138+
* When at least one explicit output is present, build() uses the explicit-output path
139+
* (fee + fee-address change only); requires sponsorshipInfo.
140+
* Uses plain Asset objects so callers do not need to import CardanoWasm.
141+
*/
142+
explicitOutput(o: ExplicitOutput): this {
143+
this._explicitOutputs.push(o);
144+
return this;
145+
}
146+
122147
/**
123148
* Initialize the transaction builder fields using the decoded transaction data
124149
*
@@ -474,8 +499,88 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
474499
return txDraft;
475500
}
476501

502+
/**
503+
* Converts a plain ExplicitOutput into a Cardano TransactionOutput and
504+
* adds it to `outputs`. Supports multiple policy IDs / asset names.
505+
*/
506+
private addExplicitOutput(output: ExplicitOutput, outputs: CardanoWasm.TransactionOutputs): void {
507+
const amount = CardanoWasm.BigNum.from_str(output.amount);
508+
const addr = util.getWalletAddress(output.address);
509+
if (output.assets && output.assets.length > 0) {
510+
const multiAsset = CardanoWasm.MultiAsset.new();
511+
for (const asset of output.assets) {
512+
const policyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset.policy_id, 'hex'));
513+
const assetName = CardanoWasm.AssetName.new(Buffer.from(asset.asset_name, 'hex'));
514+
const qty = CardanoWasm.BigNum.from_str(asset.quantity);
515+
let existingAssets = multiAsset.get(policyId);
516+
if (!existingAssets) {
517+
existingAssets = CardanoWasm.Assets.new();
518+
}
519+
existingAssets.insert(assetName, qty);
520+
multiAsset.insert(policyId, existingAssets);
521+
}
522+
const txOutputBuilder = CardanoWasm.TransactionOutputBuilder.new().with_address(addr);
523+
let txOutputAmountBuilder = txOutputBuilder.next();
524+
txOutputAmountBuilder = txOutputAmountBuilder.with_coin_and_asset(amount, multiAsset);
525+
outputs.add(txOutputAmountBuilder.build());
526+
} else {
527+
outputs.add(CardanoWasm.TransactionOutput.new(addr, CardanoWasm.Value.new(amount)));
528+
}
529+
}
530+
531+
/**
532+
* Builds a Cardano outputs collection from _explicitOutputs and appends
533+
* the fee-address change output based on the supplied fee amount.
534+
* Used exclusively by processExplicitOutputsBuild.
535+
*/
536+
private buildExplicitOutputsCollection(fee: BigNum): CardanoWasm.TransactionOutputs {
537+
if (!this._sponsorshipInfo) {
538+
throw new BuildTransactionError('explicit outputs build requires sponsorshipInfo to be set');
539+
}
540+
const outputs = CardanoWasm.TransactionOutputs.new();
541+
for (const output of this._explicitOutputs) {
542+
this.addExplicitOutput(output, outputs);
543+
}
544+
const feeBalance = CardanoWasm.BigNum.from_str(this._sponsorshipInfo.feeAddressInputBalance);
545+
const feeAddressChange = feeBalance.checked_sub(fee);
546+
if (!feeAddressChange.is_zero()) {
547+
const feeAddr = util.getWalletAddress(this._sponsorshipInfo.feeAddress);
548+
outputs.add(CardanoWasm.TransactionOutput.new(feeAddr, CardanoWasm.Value.new(feeAddressChange)));
549+
}
550+
return outputs;
551+
}
552+
553+
/**
554+
* Build path for single-asset consolidation and token withdrawal builds
555+
*
556+
* Two-pass approach mirrors processTokenBuild:
557+
* 1. Draft with zero fee → calculate actual fee.
558+
* 2. Rebuild outputs with actual fee deducted from fee-address balance.
559+
*/
560+
private processExplicitOutputsBuild(): Transaction {
561+
if (this._explicitOutputs.length === 0) {
562+
throw new BuildTransactionError('explicit outputs build requires at least one explicitOutput');
563+
}
564+
const inputs = CardanoWasm.TransactionInputs.new();
565+
this.addInputs(inputs);
566+
567+
if (this._fee.is_zero()) {
568+
const draftOutputs = this.buildExplicitOutputsCollection(BigNum.zero());
569+
const txDraft = this.prepareAdaTransactionDraft(inputs, draftOutputs, false);
570+
this.calculateFee(txDraft);
571+
this.setFeeInTransaction();
572+
}
573+
574+
const finalOutputs = this.buildExplicitOutputsCollection(this._fee);
575+
this._transaction.transaction = this.prepareAdaTransactionDraft(inputs, finalOutputs, true);
576+
return this.transaction;
577+
}
578+
477579
/** @inheritdoc */
478580
protected async buildImplementation(): Promise<Transaction> {
581+
if (this._explicitOutputs.length > 0) {
582+
return this.processExplicitOutputsBuild();
583+
}
479584
/**
480585
* Fee address utxo reservation builds a new transaction that goes through legacy build
481586
* rebuild flag is just a hack to redirect the flow to the legacy build

modules/sdk-coin-ada/test/unit/transactionBuilder.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import should from 'should';
2+
import * as sinon from 'sinon';
23
import { TransactionType } from '@bitgo/sdk-core';
34
import * as testData from '../resources';
4-
import { KeyPair, TransactionBuilderFactory } from '../../src';
5+
import { KeyPair, TransactionBuilder, TransactionBuilderFactory } from '../../src';
56
import { coins } from '@bitgo/statics';
67
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
78
import { Transaction } from '../../src/lib/transaction';
@@ -360,6 +361,105 @@ describe('ADA Transaction Builder', async () => {
360361
should.equal(sig1, sig2);
361362
});
362363

364+
describe('explicit outputs build (processExplicitOutputsBuild)', () => {
365+
afterEach(() => {
366+
sinon.restore();
367+
});
368+
369+
it('builds a sponsored tx with explicit token output, inputs, fee change, and correct asset data', async () => {
370+
const policyId = 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72';
371+
const assetNameHex = '4d494e';
372+
const tokenQty = '2000000';
373+
const explicitAda = '2000000';
374+
const feeAddress = testData.rawTx.outputAddress2.address;
375+
const recipient = testData.rawTx.outputAddress1.address;
376+
377+
const txBuilder = factory.getTransferBuilder();
378+
txBuilder.input({
379+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
380+
transaction_index: 1,
381+
});
382+
txBuilder.sponsorshipInfo({
383+
feeAddress,
384+
feeAddressInputBalance: '10000000',
385+
});
386+
txBuilder.explicitOutput({
387+
address: recipient,
388+
amount: explicitAda,
389+
assets: [
390+
{
391+
policy_id: policyId,
392+
asset_name: assetNameHex,
393+
quantity: tokenQty,
394+
},
395+
],
396+
});
397+
txBuilder.ttl(800000000);
398+
399+
const tx = (await txBuilder.build()) as Transaction;
400+
should.equal(tx.type, TransactionType.Send);
401+
const txData = tx.toJson();
402+
403+
txData.inputs.length.should.equal(1);
404+
txData.inputs[0].transaction_id.should.equal('3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21');
405+
txData.inputs[0].transaction_index.should.equal(1);
406+
407+
txData.outputs.length.should.equal(2);
408+
txData.outputs[0].address.should.equal(recipient);
409+
txData.outputs[0].amount.should.equal(explicitAda);
410+
txData.outputs[0].should.have.property('multiAssets');
411+
const expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(policyId, 'hex'));
412+
const expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(assetNameHex, 'hex'));
413+
(txData.outputs[0].multiAssets as CardanoWasm.MultiAsset)
414+
.get_asset(expectedPolicyId, expectedAssetName)
415+
.to_str()
416+
.should.equal(tokenQty);
417+
418+
txData.outputs[1].address.should.equal(feeAddress);
419+
const fee = Number(tx.getFee);
420+
(Number(txData.outputs[1].amount) + fee).should.equal(10000000);
421+
should(fee).be.above(0);
422+
});
423+
424+
it('does not call processExplicitOutputsBuild when _explicitOutputs is empty', async () => {
425+
const spy = sinon.spy(TransactionBuilder.prototype as any, 'processExplicitOutputsBuild');
426+
const txBuilder = factory.getTransferBuilder();
427+
txBuilder.input({
428+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
429+
transaction_index: 1,
430+
});
431+
txBuilder.output({
432+
address: testData.rawTx.outputAddress1.address,
433+
amount: '7823121',
434+
});
435+
txBuilder.changeAddress(testData.rawTx.outputAddress2.address, '21032023');
436+
txBuilder.ttl(800000000);
437+
await txBuilder.build();
438+
spy.called.should.be.false();
439+
});
440+
441+
it('throws when explicitOutput is set but sponsorshipInfo is missing', async () => {
442+
const txBuilder = factory.getTransferBuilder();
443+
txBuilder.input({
444+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
445+
transaction_index: 1,
446+
});
447+
txBuilder.explicitOutput({
448+
address: testData.rawTx.outputAddress1.address,
449+
amount: '2000000',
450+
assets: [
451+
{
452+
policy_id: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72',
453+
asset_name: '4d494e',
454+
quantity: '2000000',
455+
},
456+
],
457+
});
458+
txBuilder.ttl(800000000);
459+
await txBuilder.build().should.rejectedWith('explicit outputs build requires sponsorshipInfo to be set');
460+
});
461+
});
462+
363463
// NOTE: The tests below have been commented out as they are for testing during development changes. We don't
364464
// want full node tests as part of our sdk unit tests. If you are commenting these back in, add axios and
365465
// AddressFormat imports.

0 commit comments

Comments
 (0)