Skip to content

Commit 3dbbcfe

Browse files
Merge pull request #7746 from BitGo/WIN-8061_ada_sponsor_txn_example
chore(ada): ada sponsor txn example script
2 parents 5a87dda + 8c52bfd commit 3dbbcfe

File tree

1 file changed

+301
-0
lines changed

1 file changed

+301
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* ADA Sponsorship Transaction Script
3+
*
4+
* Builds and broadcasts a sponsored ADA transaction where:
5+
* - Sender sends funds to recipient
6+
* - Sponsor pays the transaction fee
7+
* - Both sender and sponsor sign the transaction
8+
* Example sponsor transaction: https://preprod.cardanoscan.io/transaction/2197f936e53414a21e4967b9530f8d40b644ed31d07364cca8ce4f424a3fb061?tab=utxo
9+
*/
10+
11+
import { coins } from '@bitgo/statics';
12+
import { TransactionBuilderFactory, Transaction } from '@bitgo/sdk-coin-ada';
13+
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
14+
import axios from 'axios';
15+
16+
const DEFAULT_CONFIG = {
17+
senderPrivateKey: '',
18+
senderAddress: '',
19+
sponsorPrivateKey: '',
20+
sponsorAddress: '',
21+
recipientAddress: '',
22+
amountToSend: '1000000',
23+
fee: '200000',
24+
minUtxoValue: '1000000',
25+
};
26+
27+
const KOIOS_API = 'https://preprod.koios.rest/api/v1';
28+
29+
// ============================================================================
30+
// Main: Build Sponsorship Transaction
31+
// ============================================================================
32+
33+
/**
34+
* Build and sign a sponsorship transaction
35+
*
36+
* Transaction structure:
37+
* - Inputs: [sender UTXOs] + [sponsor UTXOs]
38+
* - Outputs: [recipient] + [sponsor change] + [sender change]
39+
*/
40+
async function buildSponsorshipTransaction() {
41+
// Step 1: Select unspents
42+
const unspents = await selectUnspents();
43+
44+
// Step 2: Generate transaction with outputs
45+
const unsignedTx = await generateTransaction(unspents);
46+
47+
// Step 3: Sign transaction
48+
const signedTx = signTransaction(unsignedTx);
49+
50+
const txData = signedTx.toJson();
51+
const signedTxHex = signedTx.toBroadcastFormat();
52+
53+
console.log(`Transaction ID: ${txData.id}`);
54+
console.log(`Fee: ${signedTx.getFee} lovelace`);
55+
56+
// Step 4: Submit transaction
57+
try {
58+
const submittedTxHash = await submitTransaction(signedTxHex);
59+
console.log(`Submitted: https://preprod.cardanoscan.io/transaction/${submittedTxHash}`);
60+
} catch (error: unknown) {
61+
const axiosError = error as { response?: { data?: unknown }; message?: string };
62+
const errMsg = axiosError.response?.data ? JSON.stringify(axiosError.response.data) : axiosError.message;
63+
console.error(`Submission failed: ${errMsg}`);
64+
console.log(`Signed tx hex: ${signedTxHex}`);
65+
}
66+
67+
return { txId: txData.id, signedTxHex, fee: signedTx.getFee };
68+
}
69+
70+
// ============================================================================
71+
// Entry Point
72+
// ============================================================================
73+
74+
buildSponsorshipTransaction()
75+
.then(() => process.exit(0))
76+
.catch((error: Error) => {
77+
console.error('Error:', error.message);
78+
process.exit(1);
79+
});
80+
81+
// ============================================================================
82+
// Step 1: Select Unspents
83+
// ============================================================================
84+
85+
/**
86+
* Select UTXOs from sender and sponsor addresses
87+
* - Sender UTXOs cover the transfer amount
88+
* - Sponsor UTXOs cover the fee
89+
*/
90+
async function selectUnspents(): Promise<SelectedUnspents> {
91+
const [senderInfo, sponsorInfo] = await Promise.all([
92+
getAddressInfo(DEFAULT_CONFIG.senderAddress),
93+
getAddressInfo(DEFAULT_CONFIG.sponsorAddress),
94+
]);
95+
96+
if (senderInfo.utxo_set.length === 0) throw new Error('Sender has no UTXOs');
97+
if (sponsorInfo.utxo_set.length === 0) throw new Error('Sponsor has no UTXOs');
98+
99+
const amountToSend = BigInt(DEFAULT_CONFIG.amountToSend);
100+
const fee = BigInt(DEFAULT_CONFIG.fee);
101+
const minUtxoValue = BigInt(DEFAULT_CONFIG.minUtxoValue);
102+
103+
// Select sender UTXOs to cover amount + change
104+
let senderInputTotal = BigInt(0);
105+
const senderInputs: UTXO[] = [];
106+
for (const utxo of senderInfo.utxo_set) {
107+
senderInputs.push(utxo);
108+
senderInputTotal += BigInt(utxo.value);
109+
if (senderInputTotal >= amountToSend + minUtxoValue) break;
110+
}
111+
if (senderInputTotal < amountToSend) {
112+
throw new Error(`Insufficient sender funds. Have: ${senderInputTotal}, Need: ${amountToSend}`);
113+
}
114+
115+
// Select sponsor UTXOs to cover fee + change
116+
let sponsorInputTotal = BigInt(0);
117+
const sponsorInputs: UTXO[] = [];
118+
for (const utxo of sponsorInfo.utxo_set) {
119+
sponsorInputs.push(utxo);
120+
sponsorInputTotal += BigInt(utxo.value);
121+
if (sponsorInputTotal >= fee + minUtxoValue) break;
122+
}
123+
if (sponsorInputTotal < fee) {
124+
throw new Error(`Insufficient sponsor funds. Have: ${sponsorInputTotal}, Need: ${fee}`);
125+
}
126+
127+
return { senderInputs, senderInputTotal, sponsorInputs, sponsorInputTotal };
128+
}
129+
130+
// ============================================================================
131+
// Step 2: Generate and Set Outputs
132+
// ============================================================================
133+
134+
/**
135+
* Build unsigned transaction with inputs and outputs
136+
* Outputs: [recipient] + [sponsor change] + [sender change]
137+
*/
138+
async function generateTransaction(unspents: SelectedUnspents): Promise<Transaction> {
139+
const factory = new TransactionBuilderFactory(coins.get('tada'));
140+
const txBuilder = factory.getTransferBuilder();
141+
142+
const currentSlot = await getTip();
143+
const ttl = currentSlot + 7200;
144+
145+
const amountToSend = BigInt(DEFAULT_CONFIG.amountToSend);
146+
const fee = BigInt(DEFAULT_CONFIG.fee);
147+
const minUtxoValue = BigInt(DEFAULT_CONFIG.minUtxoValue);
148+
149+
// Add sender inputs
150+
for (const utxo of unspents.senderInputs) {
151+
txBuilder.input({ transaction_id: utxo.tx_hash, transaction_index: utxo.tx_index });
152+
}
153+
154+
// Add sponsor inputs
155+
for (const utxo of unspents.sponsorInputs) {
156+
txBuilder.input({ transaction_id: utxo.tx_hash, transaction_index: utxo.tx_index });
157+
}
158+
159+
// Output 1: Recipient receives the transfer amount
160+
txBuilder.output({ address: DEFAULT_CONFIG.recipientAddress, amount: amountToSend.toString() });
161+
162+
// Output 2: Sponsor change (sponsor input - fee)
163+
const sponsorChange = unspents.sponsorInputTotal - fee;
164+
if (sponsorChange >= minUtxoValue) {
165+
txBuilder.output({ address: DEFAULT_CONFIG.sponsorAddress, amount: sponsorChange.toString() });
166+
}
167+
168+
// Output 3: Sender change (handled by changeAddress)
169+
const totalInputBalance = unspents.senderInputTotal + unspents.sponsorInputTotal;
170+
txBuilder.changeAddress(DEFAULT_CONFIG.senderAddress, totalInputBalance.toString());
171+
172+
// Set TTL and fee
173+
txBuilder.ttl(ttl);
174+
txBuilder.fee(fee.toString());
175+
176+
return (await txBuilder.build()) as Transaction;
177+
}
178+
179+
// ============================================================================
180+
// Step 3: Sign Transaction
181+
// ============================================================================
182+
183+
/**
184+
* Sign transaction with both sender and sponsor keys
185+
*/
186+
function signTransaction(unsignedTx: Transaction): Transaction {
187+
const senderPrivKey = CardanoWasm.PrivateKey.from_bech32(DEFAULT_CONFIG.senderPrivateKey);
188+
const sponsorPrivKey = CardanoWasm.PrivateKey.from_bech32(DEFAULT_CONFIG.sponsorPrivateKey);
189+
190+
const txHash = CardanoWasm.hash_transaction(unsignedTx.transaction.body());
191+
192+
// Create witnesses for both parties
193+
const senderWitness = CardanoWasm.make_vkey_witness(txHash, senderPrivKey);
194+
const sponsorWitness = CardanoWasm.make_vkey_witness(txHash, sponsorPrivKey);
195+
196+
// Build witness set
197+
const witnessSet = CardanoWasm.TransactionWitnessSet.new();
198+
const vkeyWitnesses = CardanoWasm.Vkeywitnesses.new();
199+
vkeyWitnesses.add(senderWitness);
200+
vkeyWitnesses.add(sponsorWitness);
201+
witnessSet.set_vkeys(vkeyWitnesses);
202+
203+
// Create signed transaction
204+
const signedCardanoTx = CardanoWasm.Transaction.new(
205+
unsignedTx.transaction.body(),
206+
witnessSet,
207+
unsignedTx.transaction.auxiliary_data()
208+
);
209+
210+
unsignedTx.transaction = signedCardanoTx;
211+
return unsignedTx;
212+
}
213+
214+
// ============================================================================
215+
// Step 4: Submit Transaction
216+
// ============================================================================
217+
218+
/**
219+
* Submit signed transaction to the blockchain
220+
*/
221+
async function submitTransaction(signedTxHex: string): Promise<string> {
222+
const bytes = Uint8Array.from(Buffer.from(signedTxHex, 'hex'));
223+
const response = await axios.post(`${KOIOS_API}/submittx`, bytes, {
224+
headers: { 'Content-Type': 'application/cbor' },
225+
timeout: 30000,
226+
});
227+
return response.data;
228+
}
229+
230+
// ============================================================================
231+
// Helper Functions
232+
// ============================================================================
233+
234+
/**
235+
* Fetch UTXOs for an address from Koios API
236+
*/
237+
async function getAddressInfo(address: string): Promise<AddressInfo> {
238+
try {
239+
const response = await axios.post(
240+
`${KOIOS_API}/address_info`,
241+
{ _addresses: [address] },
242+
{ headers: { 'Content-Type': 'application/json' }, timeout: 30000 }
243+
);
244+
245+
if (!response.data || response.data.length === 0) {
246+
return { balance: '0', utxo_set: [] };
247+
}
248+
249+
const data = response.data[0];
250+
return {
251+
balance: data.balance || '0',
252+
utxo_set: (data.utxo_set || []).map((utxo: { tx_hash: string; tx_index: number; value: string }) => ({
253+
tx_hash: utxo.tx_hash,
254+
tx_index: utxo.tx_index,
255+
value: utxo.value,
256+
})),
257+
};
258+
} catch (error: unknown) {
259+
const axiosError = error as { response?: { status?: number }; message?: string };
260+
if (axiosError.response?.status === 400) {
261+
return { balance: '0', utxo_set: [] };
262+
}
263+
throw new Error(`Failed to fetch address info: ${axiosError.message}`);
264+
}
265+
}
266+
267+
/**
268+
* Get current blockchain tip for TTL calculation
269+
*/
270+
async function getTip(): Promise<number> {
271+
const response = await axios.get(`${KOIOS_API}/tip`, {
272+
headers: { 'Content-Type': 'application/json' },
273+
timeout: 30000,
274+
});
275+
if (!response.data || response.data.length === 0) {
276+
throw new Error('Failed to get blockchain tip');
277+
}
278+
return response.data[0].abs_slot;
279+
}
280+
281+
// ============================================================================
282+
// Types
283+
// ============================================================================
284+
285+
interface UTXO {
286+
tx_hash: string;
287+
tx_index: number;
288+
value: string;
289+
}
290+
291+
interface AddressInfo {
292+
balance: string;
293+
utxo_set: UTXO[];
294+
}
295+
296+
interface SelectedUnspents {
297+
senderInputs: UTXO[];
298+
senderInputTotal: bigint;
299+
sponsorInputs: UTXO[];
300+
sponsorInputTotal: bigint;
301+
}

0 commit comments

Comments
 (0)