Skip to content

Commit f7cb90b

Browse files
authored
Merge pull request #7070 from BitGo/WIN-7353-ada-token-build-support
feat(sdk-coin-ada): token build support
2 parents be0f3f4 + e046c39 commit f7cb90b

File tree

3 files changed

+598
-5
lines changed

3 files changed

+598
-5
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ export interface Asset {
2020
policy_id: string;
2121
asset_name: string;
2222
quantity: string;
23+
fingerprint?: string;
2324
}
2425

2526
export interface TransactionOutput {
2627
address: string;
2728
amount: string;
28-
multiAssets?: CardanoWasm.MultiAsset;
29+
multiAssets?: CardanoWasm.MultiAsset | Asset;
2930
}
3031

3132
export interface Witness {

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

Lines changed: 302 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import util, { MIN_ADA_FOR_ONE_ASSET } from './utils';
1616
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
1717
import { BigNum } from '@emurgo/cardano-serialization-lib-nodejs';
1818

19+
/**
20+
* Constants for transaction building
21+
*/
22+
const FEE_COEFFICIENTS = {
23+
/** Linear fee parameter a */
24+
A_COEFFICIENT: '44',
25+
/** Linear fee parameter b */
26+
B_COEFFICIENT: '155381',
27+
/** Additional safety margin for the fee */
28+
SAFETY_MARGIN: '440',
29+
};
30+
1931
export abstract class TransactionBuilder extends BaseTransactionBuilder {
2032
protected _transaction!: Transaction;
2133
protected _signers: KeyPair[] = [];
@@ -30,7 +42,18 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
3042
protected _withdrawals: Withdrawal[] = [];
3143
protected _type: TransactionType;
3244
protected _multiAssets: Asset[] = [];
45+
/** Address of the transaction recipient */
46+
protected _recipientAddress: string;
47+
/** Map of sender's assets by asset name */
48+
protected _senderAssetList: Record<string, any> = {};
3349
private _fee: BigNum;
50+
/** Flag indicating if this is a token transaction */
51+
private _isTokenTransaction = false;
52+
/** Deep clone of _senderAssetList - for manipulating during two iterations
53+
* - one for calculating the fee
54+
* - one for the actual transaction build with the calculated fee
55+
* */
56+
private _mutableSenderAssetList: Record<string, any> = {};
3457

3558
constructor(_coinConfig: Readonly<CoinConfig>) {
3659
super(_coinConfig);
@@ -58,17 +81,38 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
5881
return this;
5982
}
6083

61-
changeAddress(addr: string, totalInputBalance: string): this {
84+
/**
85+
* Sets the change address and input balances for the transaction
86+
* @param addr - The address where change will be sent
87+
* @param totalInputBalance - The total ADA input balance in Lovelace
88+
* @param inputAssetList - Optional map of token assets in the input
89+
*/
90+
changeAddress(addr: string, totalInputBalance: string, inputAssetList?: Record<string, any>): this {
6291
this._changeAddress = addr;
6392
this._senderBalance = totalInputBalance;
93+
this._senderAssetList = inputAssetList ?? {};
94+
this.setMutableSenderAssetList();
6495
return this;
6596
}
6697

98+
setMutableSenderAssetList() {
99+
this._mutableSenderAssetList = JSON.parse(JSON.stringify(this._senderAssetList));
100+
}
101+
67102
fee(fee: string): this {
68103
this._fee = BigNum.from_str(fee);
69104
return this;
70105
}
71106

107+
/**
108+
* Marks this transaction as a token transaction
109+
* Enables special handling for token transfers
110+
*/
111+
isTokenTransaction() {
112+
this._isTokenTransaction = true;
113+
return this;
114+
}
115+
72116
/**
73117
* Initialize the transaction builder fields using the decoded transaction data
74118
*
@@ -132,8 +176,261 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
132176
return this.transaction;
133177
}
134178

179+
/**
180+
* Builds a transaction that includes token assets
181+
* @returns The built transaction
182+
*/
183+
private processTokenBuild(): Transaction {
184+
const inputs = CardanoWasm.TransactionInputs.new();
185+
let outputs = CardanoWasm.TransactionOutputs.new();
186+
this.addInputs(inputs);
187+
// Fee is never set so far from IMS
188+
if (this._fee.is_zero()) {
189+
this.addOutputs(outputs);
190+
const txDraft = this.prepareAdaTransactionDraft(inputs, outputs, false);
191+
this.calculateFee(txDraft);
192+
this.setFeeInTransaction();
193+
}
194+
// Reset outputs to add them back with the correct change
195+
outputs = CardanoWasm.TransactionOutputs.new();
196+
this.setMutableSenderAssetList();
197+
this.addOutputs(outputs);
198+
this._transaction.transaction = this.prepareAdaTransactionDraft(inputs, outputs, true);
199+
return this.transaction;
200+
}
201+
202+
/**
203+
* Adds transaction inputs to the transaction
204+
* @param inputs - The inputs collection to add to
205+
*/
206+
private addInputs(inputs) {
207+
this._transactionInputs.forEach((input) => {
208+
inputs.add(
209+
CardanoWasm.TransactionInput.new(
210+
CardanoWasm.TransactionHash.from_bytes(Buffer.from(input.transaction_id, 'hex')),
211+
input.transaction_index
212+
)
213+
);
214+
});
215+
}
216+
217+
/**
218+
* Adds outputs to the transaction including token outputs and change
219+
* @param outputs - The outputs collection to add to
220+
*/
221+
private addOutputs(outputs) {
222+
const utxoBalance = CardanoWasm.BigNum.from_str(this._senderBalance); // Total UTXO balance
223+
const change = utxoBalance.checked_sub(this._fee);
224+
const changeAfterReceiverDeductions = this.addReceiverOutputs(outputs, change);
225+
this.addChangeOutput(changeAfterReceiverDeductions, outputs);
226+
}
227+
228+
/**
229+
* Adds receiver outputs to the transaction and calculates the remaining change
230+
* @param outputs - Transaction outputs collection
231+
* @param change - Current change amount in ADA
232+
* @returns Remaining change after adding receiver outputs
233+
*/
234+
private addReceiverOutputs(outputs, change) {
235+
this._transactionOutputs.forEach((output) => {
236+
if (!output.address) {
237+
throw new BuildTransactionError('Invalid output: missing address');
238+
}
239+
240+
const receiverAddress = output.address;
241+
const receiverAmount = output.amount;
242+
243+
try {
244+
const receiverAmountBN = CardanoWasm.BigNum.from_str(receiverAmount);
245+
change = change.checked_sub(receiverAmountBN);
246+
247+
if (change.less_than(CardanoWasm.BigNum.zero())) {
248+
throw new BuildTransactionError(
249+
'Insufficient funds: not enough ADA to cover receiver output amounts and fees'
250+
);
251+
}
252+
253+
const multiAssets = output.multiAssets as Asset;
254+
if (multiAssets) {
255+
const policyId = multiAssets.policy_id;
256+
const assetName = multiAssets.asset_name;
257+
const quantity = multiAssets.quantity;
258+
const fingerprint = multiAssets.fingerprint as string;
259+
260+
const currentQty = this._mutableSenderAssetList[fingerprint].quantity;
261+
const remainingQty = BigInt(currentQty) - BigInt(quantity);
262+
this._mutableSenderAssetList[fingerprint].quantity = (remainingQty > 0n ? remainingQty : 0n).toString();
263+
264+
if (CardanoWasm.BigNum.from_str(this._mutableSenderAssetList[fingerprint].quantity).is_zero()) {
265+
throw new BuildTransactionError('Insufficient qty: not enough token qty to cover receiver output');
266+
}
267+
268+
const minAmountNeededForAssetOutput = this.addTokensToOutput(change, outputs, receiverAddress, {
269+
policy_id: policyId,
270+
asset_name: assetName,
271+
quantity,
272+
fingerprint,
273+
});
274+
275+
change = change.checked_sub(minAmountNeededForAssetOutput);
276+
}
277+
} catch (e) {
278+
if (e instanceof BuildTransactionError) {
279+
throw e;
280+
}
281+
throw new BuildTransactionError(`Error processing output: ${e.message}`);
282+
}
283+
});
284+
return change;
285+
}
286+
287+
/**
288+
* Adds tokens to a transaction output
289+
* @param change - The current change amount in Lovelace
290+
* @param outputs - The outputs collection to add to
291+
* @param address - The recipient address
292+
* @param asset - The asset to add
293+
* @returns The minimum ADA amount needed for this asset output
294+
*/
295+
private addTokensToOutput(change: BigNum, outputs: CardanoWasm.TransactionOutputs, address, asset: Asset): BigNum {
296+
const minAmountNeededForOneAssetOutput = CardanoWasm.BigNum.from_str(MIN_ADA_FOR_ONE_ASSET);
297+
if (change.less_than(minAmountNeededForOneAssetOutput)) {
298+
throw new BuildTransactionError(
299+
`Insufficient funds: need a minimum of ${MIN_ADA_FOR_ONE_ASSET} lovelace per output to construct token transactions`
300+
);
301+
}
302+
this.buildTokens(asset, minAmountNeededForOneAssetOutput, outputs, address);
303+
return minAmountNeededForOneAssetOutput;
304+
}
305+
306+
/**
307+
* Builds token outputs for a transaction
308+
* @param asset - The asset to include in the output
309+
* @param minAmountNeededForOneAssetOutput - Minimum ADA required for this asset output
310+
* @param outputs - Transaction outputs collection
311+
* @param address - Recipient address
312+
*/
313+
private buildTokens(asset: Asset, minAmountNeededForOneAssetOutput, outputs, address) {
314+
let txOutputBuilder = CardanoWasm.TransactionOutputBuilder.new();
315+
const toAddress = util.getWalletAddress(address);
316+
txOutputBuilder = txOutputBuilder.with_address(toAddress);
317+
let txOutputAmountBuilder = txOutputBuilder.next();
318+
const assetName = CardanoWasm.AssetName.new(Buffer.from(asset.asset_name, 'hex'));
319+
const policyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset.policy_id, 'hex'));
320+
const multiAsset = CardanoWasm.MultiAsset.new();
321+
const assets = CardanoWasm.Assets.new();
322+
assets.insert(assetName, CardanoWasm.BigNum.from_str(asset.quantity));
323+
multiAsset.insert(policyId, assets);
324+
325+
txOutputAmountBuilder = txOutputAmountBuilder.with_coin_and_asset(minAmountNeededForOneAssetOutput, multiAsset);
326+
327+
const txOutput = txOutputAmountBuilder.build();
328+
outputs.add(txOutput);
329+
}
330+
331+
/**
332+
* Adds a change output to the transaction
333+
* @param change - The change amount in Lovelace
334+
* @param outputs - The outputs collection to add to
335+
*/
336+
private addChangeOutput(change, outputs) {
337+
const changeAddress = util.getWalletAddress(this._changeAddress);
338+
Object.keys(this._mutableSenderAssetList).forEach((fingerprint) => {
339+
const asset = this._mutableSenderAssetList[fingerprint];
340+
const changeQty = asset.quantity;
341+
const policyId = asset.policy_id;
342+
const assetName = asset.asset_name;
343+
344+
if (CardanoWasm.BigNum.from_str(changeQty).is_zero()) {
345+
return;
346+
}
347+
348+
const minAmountNeededForAssetOutput = this.addTokensToOutput(change, outputs, this._changeAddress, {
349+
policy_id: policyId,
350+
asset_name: assetName,
351+
quantity: changeQty,
352+
fingerprint,
353+
});
354+
change = change.checked_sub(minAmountNeededForAssetOutput);
355+
});
356+
if (!change.is_zero()) {
357+
const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change));
358+
outputs.add(changeOutput);
359+
}
360+
}
361+
362+
/**
363+
* Sets the calculated fee in the transaction
364+
*/
365+
private setFeeInTransaction() {
366+
this._transaction.fee(this._fee.to_str());
367+
}
368+
369+
/**
370+
* Calculates the transaction fee based on transaction size
371+
* @param txDraft - Draft transaction to calculate fee for
372+
*/
373+
private calculateFee(txDraft) {
374+
const linearFee = CardanoWasm.LinearFee.new(
375+
CardanoWasm.BigNum.from_str(FEE_COEFFICIENTS.A_COEFFICIENT),
376+
CardanoWasm.BigNum.from_str(FEE_COEFFICIENTS.B_COEFFICIENT)
377+
);
378+
379+
// Calculate the fee based off our dummy transaction
380+
const fee = CardanoWasm.min_fee(txDraft, linearFee).checked_add(BigNum.from_str(FEE_COEFFICIENTS.SAFETY_MARGIN));
381+
this._fee = fee;
382+
}
383+
384+
private prepareAdaTransactionDraft(inputs, outputs, refreshSignatures = false) {
385+
const txBody = CardanoWasm.TransactionBody.new_tx_body(inputs, outputs, this._fee);
386+
txBody.set_ttl(CardanoWasm.BigNum.from_str(this._ttl.toString()));
387+
const txHash = CardanoWasm.hash_transaction(txBody);
388+
389+
// we add witnesses once so that we can get the appropriate amount of signers for calculating the fee
390+
const witnessSet = CardanoWasm.TransactionWitnessSet.new();
391+
const vkeyWitnesses = CardanoWasm.Vkeywitnesses.new();
392+
this._signers.forEach((keyPair) => {
393+
const prv = keyPair.getKeys().prv as string;
394+
const vkeyWitness = CardanoWasm.make_vkey_witness(
395+
txHash,
396+
CardanoWasm.PrivateKey.from_normal_bytes(Buffer.from(prv, 'hex'))
397+
);
398+
vkeyWitnesses.add(vkeyWitness);
399+
});
400+
if (refreshSignatures) {
401+
this._transaction.signature.length = 0;
402+
}
403+
this.getAllSignatures().forEach((signature) => {
404+
const vkey = CardanoWasm.Vkey.new(CardanoWasm.PublicKey.from_bytes(Buffer.from(signature.publicKey.pub, 'hex')));
405+
const ed255Sig = CardanoWasm.Ed25519Signature.from_bytes(signature.signature);
406+
vkeyWitnesses.add(CardanoWasm.Vkeywitness.new(vkey, ed255Sig));
407+
});
408+
if (vkeyWitnesses.len() === 0) {
409+
const prv = CardanoWasm.PrivateKey.generate_ed25519();
410+
const vkeyWitness = CardanoWasm.make_vkey_witness(txHash, prv);
411+
vkeyWitnesses.add(vkeyWitness);
412+
if (this._type !== TransactionType.Send) {
413+
vkeyWitnesses.add(vkeyWitness);
414+
}
415+
}
416+
witnessSet.set_vkeys(vkeyWitnesses);
417+
418+
// add in certificates to get mock size
419+
const draftCerts = CardanoWasm.Certificates.new();
420+
for (const cert of this._certs) {
421+
draftCerts.add(cert);
422+
}
423+
txBody.set_certs(draftCerts);
424+
425+
const txDraft = CardanoWasm.Transaction.new(txBody, witnessSet);
426+
return txDraft;
427+
}
428+
135429
/** @inheritdoc */
136430
protected async buildImplementation(): Promise<Transaction> {
431+
if (this._isTokenTransaction) {
432+
return this.processTokenBuild();
433+
}
137434
const inputs = CardanoWasm.TransactionInputs.new();
138435
this._transactionInputs.forEach((input) => {
139436
inputs.add(
@@ -291,9 +588,10 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
291588
// reset the outputs collection because now our last output has changed
292589
outputs = CardanoWasm.TransactionOutputs.new();
293590
this._transactionOutputs.forEach((output) => {
294-
if (output.multiAssets) {
295-
const policyId = output.multiAssets.keys().get(0);
296-
const assets = output.multiAssets.get(policyId);
591+
const multiAssets = output.multiAssets as CardanoWasm.MultiAsset;
592+
if (multiAssets) {
593+
const policyId = multiAssets.keys().get(0);
594+
const assets = multiAssets.get(policyId);
297595
const assetName = assets!.keys().get(0);
298596
const quantity = assets!.get(assetName);
299597
let txOutputBuilder = CardanoWasm.TransactionOutputBuilder.new();

0 commit comments

Comments
 (0)