@fundtokens/builders 0.1.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ import {
2
+ Contract,
3
+ TransactionBuilder,
4
+ } from 'cashscript';
5
+ import {
6
+ swapEndianness,
7
+ hash256,
8
+ hexToBin,
9
+ binToHex,
10
+ cashAddressToLockingBytecode,
11
+ } from '@bitauth/libauth';
12
+ import { BitcoinCategory } from './constants.js';
13
+ import {
14
+ getFundBin,
15
+ hashFund,
16
+ getBestFee,
17
+ getRandomInt,
18
+ withDust,
19
+ } from './utils.js';
20
+
21
+ import managerJson from './art/manager.json' with { type: 'json' };
22
+ import fundJson from './art/fund.json' with { type: 'json' };
23
+ import assetJson from './art/asset.json' with { type: 'json' };
24
+ import feeJson from './art/fee.json' with { type: 'json' };
25
+ import simpleVaultJson from './art/simple_vault.json' with { type: 'json' };
26
+
27
+ const sortDecreasingTokenAmount = (a, b) => b.token?.amount - a.token?.amount;
28
+
29
+ export default class FundTokenTransactionBuilder extends TransactionBuilder {
30
+ #system = {
31
+ inflow: '', // 32 byte, token id
32
+ outflow: '', // 32 byte, token id
33
+ authorization: '', // 32 byte, token id
34
+ fee: {
35
+ nft: '', // 32 byte, token id
36
+ value: -1n, // bigint
37
+ },
38
+ };
39
+ #swapped = {
40
+ inflow: '',
41
+ outflow: '',
42
+ fee: {
43
+ nft: '',
44
+ },
45
+ };
46
+ #fund = {
47
+ category: '',
48
+ amount: -1n,
49
+ satoshis: -1n,
50
+ assets: null,
51
+ };
52
+ #meta = {
53
+ isBitcoinFund: false,
54
+ };
55
+ #contracts = {
56
+ managerContract: null,
57
+ fundContract: null,
58
+ satoshiAssetContract: null,
59
+ assetContracts: null,
60
+ feeContract: null,
61
+ feeVaultContract: null
62
+ };
63
+ #logger = null;
64
+
65
+ constructor({
66
+ provider,
67
+ system,
68
+ logger,
69
+ fund,
70
+ }) {
71
+ if (!system) {
72
+ throw new Error('No system configuration provided, unable to continue');
73
+ }
74
+ super({ provider });
75
+ this.#system = system;
76
+ this.#swapped = {
77
+ inflow: swapEndianness(system.inflow),
78
+ outflow: swapEndianness(system.outflow),
79
+ authorization: swapEndianness(system.authorization),
80
+ fee: {
81
+ nft: swapEndianness(system.fee.nft),
82
+ },
83
+ };
84
+ this.#fund = fund;
85
+ this.#meta = {
86
+ isBitcoinFund: this.#fund.satoshis > 0,
87
+ };
88
+ this.#logger = logger ?? console;
89
+ this.#buildContracts();
90
+ }
91
+
92
+ // build and get the contracts for this fund
93
+ #buildContracts() {
94
+ const {
95
+ category,
96
+ assets,
97
+ } = this.#fund;
98
+ const fundHash = hashFund(this.#fund);
99
+
100
+ const assetContracts = [];
101
+
102
+ let satoshiAssetContract = undefined;
103
+ if(this.#fund.satoshis > 0) {
104
+ satoshiAssetContract = new Contract(assetJson, [this.#swapped.outflow, fundHash, BitcoinCategory], { provider: this.provider });
105
+ }
106
+
107
+ assets.forEach(a => {
108
+ const fundAssetCategory = swapEndianness(a.category);
109
+
110
+ // 32 32 32
111
+ const assetContract = new Contract(assetJson, [this.#swapped.outflow, fundHash, fundAssetCategory], { provider: this.provider });
112
+
113
+ assetContracts.push(assetContract);
114
+ });
115
+
116
+ // 32 32 32 32 + 4 128 132 * 2 264
117
+ const fundContract = new Contract(fundJson, [this.#swapped.inflow, this.#swapped.outflow, swapEndianness(category), fundHash], { provider: this.provider });
118
+
119
+ const feeVaultContract = new Contract(simpleVaultJson, [this.#swapped.authorization], { provider: this.provider });
120
+ const feeVaultLockingBytecode = binToHex(cashAddressToLockingBytecode(feeVaultContract.tokenAddress).bytecode);
121
+ const feeContract = new Contract(feeJson, [this.#swapped.authorization, feeVaultLockingBytecode, this.#swapped.fee.nft, BigInt(this.#system.fee.value)], { provider: this.provider });
122
+
123
+ const managerContract = new Contract(managerJson, [
124
+ binToHex(hash256(hexToBin(feeContract.bytecode))),
125
+ this.#swapped.inflow,
126
+ this.#swapped.outflow,
127
+ swapEndianness(category),
128
+ fundHash,
129
+ hexToBin(fundJson.debug.bytecode),
130
+ hexToBin(assetJson.debug.bytecode),
131
+ ], { provider: this.provider });
132
+
133
+ this.#contracts = { managerContract, fundContract, assetContracts, feeContract, satoshiAssetContract, feeVaultContract };
134
+ }
135
+
136
+ getContracts() {
137
+ return this.#contracts;
138
+ }
139
+
140
+ // TODO: Scaling fund UTXO selection
141
+ // As new threads are added to the fund token contract, we should select for inflow tx the UTXO with the most tokens
142
+ // Although this means we still target one thread, should have a range maybe and then randomly select
143
+ // Maybe should just randomly select enough to fulfills the needs for the tx
144
+
145
+ // This method should be called while the transaction has same transaction input and output lengths
146
+ // The consuming app is responsible for adding an output for Bitcoin change, fund token minted, and token change
147
+ async addInflow({
148
+ amount,
149
+ payBy,
150
+ }) {
151
+ this.#logger.log('transaction builder...adding minting transaction');
152
+
153
+ const { managerContract, fundContract, assetContracts, feeContract, feeVaultContract } = this.#contracts;
154
+
155
+ const inflowUtxos = (await managerContract.getUtxos()).filter(u => u.token?.category === this.#system.inflow);
156
+ const fundUtxos = (await fundContract.getUtxos()).filter(u => u.token?.category === this.#fund.category);
157
+ const bestFee = await getBestFee({ feeVaultContract, feeContract, payBy, fee: this.#system.fee, authorization: this.#system.authorization });
158
+
159
+ if (!inflowUtxos?.length || !fundUtxos?.length || !bestFee) {
160
+ this.#logger.error('Missing required UTXO', !inflowUtxos?.length, !fundUtxos?.length, !bestFee);
161
+ throw new Error('Missing required UTXO');
162
+ }
163
+
164
+ const inflowUtxo = inflowUtxos[getRandomInt(inflowUtxos.length)];
165
+ const fundUtxo = fundUtxos[getRandomInt(fundUtxos.length)];
166
+ const feeUtxo = bestFee.utxo;
167
+
168
+ const inflowAmount = this.#fund.amount * amount;
169
+ const fundChangeAmount = fundUtxo.token.amount - inflowAmount;
170
+
171
+ const fundContractOutput = fundChangeAmount > 0 ? withDust({
172
+ to: fundContract.tokenAddress,
173
+ token: {
174
+ category: this.#fund.category,
175
+ amount: fundChangeAmount,
176
+ },
177
+ }) : withDust({
178
+ to: fundContract.tokenAddress,
179
+ });
180
+
181
+ const bitcoinOutputs = [];
182
+ this.#meta.isBitcoinFund && bitcoinOutputs.push({ to: this.#contracts.satoshiAssetContract.tokenAddress, amount: this.#fund.satoshis * amount });
183
+
184
+ this.addInputs([
185
+ {
186
+ ...inflowUtxo,
187
+ unlocker: managerContract.unlock.inflow(getFundBin(this.#fund)),
188
+ },
189
+ {
190
+ ...fundUtxo,
191
+ unlocker: fundContract.unlock.mint(),
192
+ },
193
+ {
194
+ ...feeUtxo,
195
+ unlocker: feeContract.unlock.pay(),
196
+ }
197
+ ])
198
+ .addOutputs([
199
+ withDust({
200
+ to: managerContract.tokenAddress,
201
+ token: {
202
+ ...inflowUtxo.token,
203
+ },
204
+ }),
205
+ fundContractOutput,
206
+ ...bestFee.outputs,
207
+ ...bitcoinOutputs,
208
+ ...assetContracts.map((assetContract, i) => {
209
+ return withDust({
210
+ to: assetContract.tokenAddress,
211
+ token: {
212
+ category: this.#fund.assets[i].category,
213
+ amount: this.#fund.assets[i].amount * amount,
214
+ }
215
+ });
216
+ }),
217
+ ]);
218
+ this.#logger.log('finished adding mint transaction i/o');
219
+ return this;
220
+ }
221
+
222
+ // TODO: Scaling fund UTXO selection
223
+ // As new threads are added to the fund token contract, we should select for outflow tx the UTXO with the least tokens
224
+ // Although this means we still target one thread, should have a range maybe and then randomly select
225
+ // Maybe should just randomly select one that fulfills the needs
226
+
227
+ // This method should be called while the transaction has same transaction input and output lengths
228
+ // The consuming user is responsible for adding inputs for the fund token
229
+ // The consuming app is responsible for adding an outputs for Bitcoin change and token change
230
+ async addOutflow({
231
+ amount,
232
+ payBy,
233
+ bufferHex,
234
+ }) {
235
+ this.#logger.log('transaction builder...adding redemption transaction');
236
+
237
+ const { managerContract, fundContract, assetContracts, feeContract, satoshiAssetContract, feeVaultContract } = this.#contracts;
238
+
239
+ //
240
+ const outflowUtxos = (await managerContract.getUtxos()).filter(u => u.token?.category === this.#system.outflow);
241
+ if (!outflowUtxos.length) {
242
+ throw new Error(`Missing required outflow ${this.#system.outflow} UTXO.`);
243
+ }
244
+ const outflowUtxo = outflowUtxos[0];
245
+
246
+
247
+ //
248
+ const fundUtxos = await fundContract.getUtxos();
249
+
250
+ if (!fundUtxos.length) {
251
+ throw new Error(`Missing required fund ${this.#fund.category} UTXO. Send dust UTXO to contract and redeem again.`)
252
+ }
253
+
254
+ const existingFundUtxo = fundUtxos.filter(u => u.token?.category === this.#fund.category).sort(sortDecreasingTokenAmount);
255
+ const fundUtxo = existingFundUtxo.length ? existingFundUtxo[getRandomInt(existingFundUtxo.length)] : fundUtxos[getRandomInt(fundUtxos.length)];
256
+
257
+
258
+ //
259
+ const outflowAmount = this.#fund.amount * amount;
260
+ const updatedFundAmount = (fundUtxo.token?.amount ?? 0n) + outflowAmount;
261
+
262
+ const bestFee = await getBestFee({ feeVaultContract, feeContract, payBy, fee: this.#system.fee, authorization: this.#system.authorization });
263
+ const feeUtxo = bestFee.utxo;
264
+
265
+ const satoshiAssetInputs = [];
266
+ const satoshiAssetOutputs = [];
267
+ const satoshiAssetChangeAmounts = [];
268
+
269
+ const calcSatoshiAsset = async () => {
270
+ if (this.#meta.isBitcoinFund) {
271
+ const satoshiAssetUtxos = (await satoshiAssetContract.getUtxos()).filter(u => !u.token);
272
+ if(!satoshiAssetUtxos) {
273
+ throw new Error('Missing required satoshi asset UTXO');
274
+ }
275
+ let satoshiAmountAdded = 0n;
276
+ for (let index = 0; index < satoshiAssetUtxos.length; ++index) {
277
+ satoshiAssetInputs.push({
278
+ ...satoshiAssetUtxos[index],
279
+ unlocker: this.#contracts.satoshiAssetContract.unlock.release()
280
+ });
281
+ satoshiAmountAdded += satoshiAssetUtxos[index].satoshis;
282
+ if (satoshiAmountAdded >= amount * this.#fund.satoshis) {
283
+ satoshiAssetChangeAmounts.push(satoshiAmountAdded - (amount * this.#fund.satoshis));
284
+ break;
285
+ }
286
+ }
287
+
288
+ for (let i = 0; i < satoshiAssetChangeAmounts.length; ++i) {
289
+ if (!satoshiAssetChangeAmounts[i]) {
290
+ continue;
291
+ }
292
+ this.#logger.log('adding satoshi output change');
293
+ satoshiAssetOutputs.push({
294
+ to: satoshiAssetContract.tokenAddress,
295
+ amount: satoshiAssetChangeAmounts[i],
296
+ });
297
+ }
298
+ }
299
+ };
300
+
301
+ await calcSatoshiAsset();
302
+
303
+ const assetInputs = [];
304
+ const assetOutputs = [];
305
+ const assetChangeAmounts = [];
306
+
307
+ const calcTokenAssets = async () => {
308
+ for (let i = 0; i < assetContracts.length; ++i) {
309
+ const assetUtxos = (await assetContracts[i].getUtxos()).filter(u => u.token?.category === this.#fund.assets[i].category).sort(sortDecreasingTokenAmount);
310
+ if (!assetUtxos.length) {
311
+ throw new Error(`Missing required asset '${this.#fund.assets[i].category}' UTXO`);
312
+ }
313
+ let tokenAmountAdded = 0n;
314
+ for (let j = 0; j < assetUtxos.length; ++j) {
315
+ assetInputs.push({
316
+ ...assetUtxos[j],
317
+ unlocker: assetContracts[i].unlock.release()
318
+ });
319
+ tokenAmountAdded += assetUtxos[j].token.amount;
320
+ if (tokenAmountAdded >= amount * this.#fund.assets[i].amount) {
321
+ assetChangeAmounts.push(tokenAmountAdded - (amount * this.#fund.assets[i].amount));
322
+ break;
323
+ }
324
+ }
325
+ }
326
+
327
+ for (let i = 0; i < assetChangeAmounts.length; ++i) {
328
+ if (!assetChangeAmounts[i]) {
329
+ continue;
330
+ }
331
+ assetOutputs.push(withDust({
332
+ to: assetContracts[i].tokenAddress,
333
+ token: {
334
+ category: this.#fund.assets[i].category,
335
+ amount: assetChangeAmounts[i],
336
+ },
337
+ }));
338
+ }
339
+ }
340
+
341
+ await calcTokenAssets();
342
+
343
+ // 180 is good up to 12
344
+ const densityBuffer = this.#fund.assets.length <= 8 ? '' : '00'.repeat(180 * (this.#fund.assets.length - 8));
345
+
346
+ this.addInputs([
347
+ {
348
+ ...outflowUtxo,
349
+ unlocker: managerContract.unlock.outflow(getFundBin(this.#fund), bufferHex ?? densityBuffer) // TODO: support density better, need to calculate the "expensive" operations // '00'.repeat(4325)
350
+ },
351
+ {
352
+ ...fundUtxo,
353
+ unlocker: fundContract.unlock.redeem()
354
+ },
355
+ {
356
+ ...feeUtxo,
357
+ unlocker: feeContract.unlock.pay()
358
+ },
359
+ ...satoshiAssetInputs,
360
+ ...assetInputs
361
+ ])
362
+ .addOutputs([
363
+ withDust({
364
+ to: managerContract.tokenAddress,
365
+ token: {
366
+ ...outflowUtxo.token,
367
+ },
368
+ }),
369
+ withDust({
370
+ to: fundContract.tokenAddress,
371
+ token: {
372
+ category: this.#fund.category,
373
+ amount: updatedFundAmount,
374
+ },
375
+ }),
376
+ ...bestFee.outputs,
377
+ ...satoshiAssetOutputs,
378
+ ...assetOutputs
379
+ ]);
380
+
381
+ this.#logger.log('finished adding redemption transaction i/o');
382
+ return this;
383
+ }
384
+ }
package/LICENSE ADDED
@@ -0,0 +1 @@
1
+ Copyright (c) 2026 FoldingCash LLC, doing business as Fun(d)Tokens. All rights reserved.