@otoplo/wallet-common 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/index.d.ts +2278 -0
- package/dist/index.js +2005 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/index.ts +5 -0
- package/src/persistence/datastore/db.ts +47 -0
- package/src/persistence/datastore/index.ts +2 -0
- package/src/persistence/datastore/kv.ts +129 -0
- package/src/persistence/index.ts +2 -0
- package/src/persistence/wallet-db.ts +251 -0
- package/src/services/asset.ts +220 -0
- package/src/services/cache.ts +54 -0
- package/src/services/discovery.ts +110 -0
- package/src/services/index.ts +8 -0
- package/src/services/key.ts +55 -0
- package/src/services/rostrum.ts +214 -0
- package/src/services/session.ts +225 -0
- package/src/services/transaction.ts +388 -0
- package/src/services/tx-transformer.ts +244 -0
- package/src/services/wallet.ts +650 -0
- package/src/state/hooks.ts +45 -0
- package/src/state/index.ts +3 -0
- package/src/state/slices/auth.ts +28 -0
- package/src/state/slices/dapp.ts +32 -0
- package/src/state/slices/index.ts +6 -0
- package/src/state/slices/loader.ts +31 -0
- package/src/state/slices/notifications.ts +44 -0
- package/src/state/slices/status.ts +70 -0
- package/src/state/slices/wallet.ts +112 -0
- package/src/state/store.ts +24 -0
- package/src/types/dapp.types.ts +21 -0
- package/src/types/db.types.ts +142 -0
- package/src/types/index.ts +5 -0
- package/src/types/notification.types.ts +13 -0
- package/src/types/rostrum.types.ts +161 -0
- package/src/types/wallet.types.ts +62 -0
- package/src/utils/asset.ts +103 -0
- package/src/utils/common.ts +159 -0
- package/src/utils/enums.ts +22 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/keypath.ts +15 -0
- package/src/utils/price.ts +40 -0
- package/src/utils/seed.ts +57 -0
- package/src/utils/vault.ts +39 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import type { PrivateKey, PublicKey, Script, UTXO } from "libnexa-ts";
|
|
2
|
+
import { Address, AddressType, Transaction, TransactionBuilder, UnitUtils } from "libnexa-ts";
|
|
3
|
+
import type { KeyManager } from "./key";
|
|
4
|
+
import type { RostrumService } from "./rostrum";
|
|
5
|
+
import type { AssetMovement, TransactionDTO, TransactionEntity, TransactionType } from "../types/db.types";
|
|
6
|
+
import { currentTimestamp, isNullOrEmpty, isValidNexaAddress, MAX_INT64, tokenAmountToAssetAmount, tokenIdToHex, VAULT_FIRST_BLOCK } from "../utils/common";
|
|
7
|
+
import type { WalletDB } from "../persistence/wallet-db";
|
|
8
|
+
import type { ITXHistory } from "../types/rostrum.types";
|
|
9
|
+
import type { AddressKey } from "../types/wallet.types";
|
|
10
|
+
|
|
11
|
+
export interface TxTemplateData {
|
|
12
|
+
templateScript: Script;
|
|
13
|
+
constraintScript: Script;
|
|
14
|
+
visibleArgs: any[];
|
|
15
|
+
publicKey: PublicKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TxOptions {
|
|
19
|
+
isConsolidate?: boolean;
|
|
20
|
+
templateData?: TxTemplateData;
|
|
21
|
+
feeFromAmount?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class TransactionService {
|
|
25
|
+
|
|
26
|
+
private readonly rostrumService: RostrumService;
|
|
27
|
+
private readonly keyManager: KeyManager;
|
|
28
|
+
private readonly walletDb: WalletDB;
|
|
29
|
+
|
|
30
|
+
private readonly MAX_INPUTS_OUTPUTS = 250;
|
|
31
|
+
|
|
32
|
+
public constructor(rostrumService: RostrumService, keysManager: KeyManager, walletDb: WalletDB) {
|
|
33
|
+
this.rostrumService = rostrumService;
|
|
34
|
+
this.keyManager = keysManager;
|
|
35
|
+
this.walletDb = walletDb;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async broadcastTransaction(txHex: string): Promise<string> {
|
|
39
|
+
return this.rostrumService.broadcast(txHex);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public async fetchTransactionsHistory(address: string, fromHeight: number): Promise<{txs: ITXHistory[], lastHeight: number}> {
|
|
43
|
+
let maxHeight = fromHeight;
|
|
44
|
+
|
|
45
|
+
const fromHeightFilter = fromHeight > 0 ? fromHeight + 1 : 0;
|
|
46
|
+
const txHistory = await this.rostrumService.getTransactionsHistory(address, fromHeightFilter);
|
|
47
|
+
|
|
48
|
+
for (const tx of txHistory) {
|
|
49
|
+
maxHeight = Math.max(maxHeight, tx.height);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {txs: txHistory, lastHeight: maxHeight};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public async fetchVaultTransactions(address: string): Promise<TransactionDTO[]> {
|
|
56
|
+
const txHistory = await this.rostrumService.getTransactionsHistory(address, VAULT_FIRST_BLOCK);
|
|
57
|
+
|
|
58
|
+
const transactions: Promise<TransactionDTO>[] = [];
|
|
59
|
+
for (const tx of txHistory) {
|
|
60
|
+
const txEntry = this.classifyTransaction(tx.tx_hash, [address]);
|
|
61
|
+
transactions.push(txEntry);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Promise.all(transactions);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public async classifyAndSaveTransaction(accountId: number, txHash: string, myAddresses: string[]): Promise<void> {
|
|
68
|
+
const txDto = await this.classifyTransaction(txHash, myAddresses);
|
|
69
|
+
txDto.accountId = accountId;
|
|
70
|
+
|
|
71
|
+
const txEntity: TransactionEntity = {
|
|
72
|
+
...txDto,
|
|
73
|
+
othersOutputs: JSON.stringify(txDto.othersOutputs),
|
|
74
|
+
myOutputs: JSON.stringify(txDto.myOutputs),
|
|
75
|
+
myInputs: JSON.stringify(txDto.myInputs)
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const involvedAssets = new Set<string>();
|
|
79
|
+
txDto.myInputs.forEach(m => m.assetId && involvedAssets.add(m.assetId));
|
|
80
|
+
txDto.myOutputs.forEach(m => m.assetId && involvedAssets.add(m.assetId));
|
|
81
|
+
|
|
82
|
+
for (const assetId of involvedAssets) {
|
|
83
|
+
await this.walletDb.addAssetTransaction({
|
|
84
|
+
accountId: accountId,
|
|
85
|
+
assetId: assetId,
|
|
86
|
+
txIdem: txDto.txIdem,
|
|
87
|
+
time: txDto.time
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await this.walletDb.addLocalTransaction(txEntity);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async classifyTransaction(txHash: string, myAddresses: string[]): Promise<TransactionDTO> {
|
|
94
|
+
const t = await this.rostrumService.getTransaction(txHash);
|
|
95
|
+
|
|
96
|
+
const myInputs: AssetMovement[] = [];
|
|
97
|
+
const othersInputs: AssetMovement[] = [];
|
|
98
|
+
const myOutputs: AssetMovement[] = [];
|
|
99
|
+
const othersOutputs: AssetMovement[] = [];
|
|
100
|
+
|
|
101
|
+
for (const vin of t.vin) {
|
|
102
|
+
const movement: AssetMovement = {
|
|
103
|
+
address: vin.addresses[0],
|
|
104
|
+
nexaAmount: vin.value_satoshi.toString(),
|
|
105
|
+
assetId: vin.token_id_hex ?? "",
|
|
106
|
+
assetAmount: tokenAmountToAssetAmount(vin.groupQuantity)
|
|
107
|
+
};
|
|
108
|
+
if (myAddresses.includes(movement.address)) {
|
|
109
|
+
myInputs.push(movement);
|
|
110
|
+
} else {
|
|
111
|
+
othersInputs.push(movement);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const vout of t.vout) {
|
|
116
|
+
if (isNullOrEmpty(vout.scriptPubKey.addresses)) continue;
|
|
117
|
+
const movement: AssetMovement = {
|
|
118
|
+
address: vout.scriptPubKey.addresses[0],
|
|
119
|
+
nexaAmount: vout.value_satoshi.toString(),
|
|
120
|
+
assetId: vout.scriptPubKey.token_id_hex ?? "",
|
|
121
|
+
assetAmount: tokenAmountToAssetAmount(vout.scriptPubKey.groupQuantity)
|
|
122
|
+
};
|
|
123
|
+
if (myAddresses.includes(movement.address)) {
|
|
124
|
+
myOutputs.push(movement);
|
|
125
|
+
} else {
|
|
126
|
+
othersOutputs.push(movement);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let type: TransactionType;
|
|
131
|
+
if (myInputs.length === 0) {
|
|
132
|
+
type = 'receive';
|
|
133
|
+
} else if (myOutputs.length === 0) {
|
|
134
|
+
type = 'send';
|
|
135
|
+
} else if (othersInputs.length === 0 && othersOutputs.length === 0) {
|
|
136
|
+
type = 'self';
|
|
137
|
+
} else if (othersInputs.length === 0 && othersOutputs.length > 0) {
|
|
138
|
+
type = 'send';
|
|
139
|
+
} else if (othersInputs.length > 0 && othersOutputs.length === 0) {
|
|
140
|
+
type = 'receive';
|
|
141
|
+
} else {
|
|
142
|
+
type = 'swap';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isConfirmed = t.height > 0;
|
|
146
|
+
const txDto: TransactionDTO = {
|
|
147
|
+
accountId: 0, // Will be set in classifyAndSaveTransaction
|
|
148
|
+
txId: t.txid,
|
|
149
|
+
txIdem: t.txidem,
|
|
150
|
+
time: isConfirmed ? t.time : currentTimestamp(),
|
|
151
|
+
height: isConfirmed ? t.height : 0,
|
|
152
|
+
type: type,
|
|
153
|
+
fee: t.fee_satoshi.toString(),
|
|
154
|
+
othersOutputs: type == 'send' || type == 'swap' ? othersOutputs : [],
|
|
155
|
+
myOutputs: myOutputs,
|
|
156
|
+
myInputs: myInputs
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return txDto;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public async buildAndSignTransferTransaction(
|
|
163
|
+
from: AddressKey[],
|
|
164
|
+
toAddr: string,
|
|
165
|
+
toChange: string,
|
|
166
|
+
amount: string,
|
|
167
|
+
feeFromAmount?: boolean,
|
|
168
|
+
token?: string,
|
|
169
|
+
feePerByte?: number,
|
|
170
|
+
data?: string
|
|
171
|
+
): Promise<Transaction> {
|
|
172
|
+
const txOptions: TxOptions = {
|
|
173
|
+
feeFromAmount: feeFromAmount
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const txBuilder = this.prepareTransaction(toAddr, amount, token, data);
|
|
177
|
+
if (feePerByte) {
|
|
178
|
+
txBuilder.feePerByte(feePerByte);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let tokenPrivKeys = new Map<string, PrivateKey>();
|
|
182
|
+
if (token) {
|
|
183
|
+
tokenPrivKeys = await this.populateTokenInputsAndChange(txBuilder, from, toChange, token, BigInt(amount));
|
|
184
|
+
}
|
|
185
|
+
const privKeys = await this.populateNexaInputsAndChange(txBuilder, from, toChange, txOptions);
|
|
186
|
+
tokenPrivKeys.forEach((v, k) => privKeys.set(k, v));
|
|
187
|
+
|
|
188
|
+
return await this.finalizeTransaction(txBuilder, Array.from(privKeys.values()));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public async buildAndSignConsolidateTransaction(from: AddressKey[], toChange: string, templateData?: TxTemplateData): Promise<Transaction> {
|
|
192
|
+
const txOptions: TxOptions = {
|
|
193
|
+
isConsolidate: true,
|
|
194
|
+
templateData: templateData
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const txBuilder = new TransactionBuilder();
|
|
198
|
+
const privKeys = await this.populateNexaInputsAndChange(txBuilder, from, toChange, txOptions);
|
|
199
|
+
|
|
200
|
+
return this.finalizeTransaction(txBuilder, Array.from(privKeys.values()));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private prepareTransaction(toAddr: string, amount: string, token?: string, data?: string): TransactionBuilder {
|
|
204
|
+
if (!isValidNexaAddress(toAddr) && !isValidNexaAddress(toAddr, AddressType.PayToPublicKeyHash)) {
|
|
205
|
+
throw new Error('Invalid Address.');
|
|
206
|
+
}
|
|
207
|
+
if ((token && BigInt(amount) < 1n) || (!token && parseInt(amount) < Transaction.DUST_AMOUNT)) {
|
|
208
|
+
throw new Error("The amount is too low.");
|
|
209
|
+
}
|
|
210
|
+
if ((token && BigInt(amount) > MAX_INT64) || (!token && parseInt(amount) > Transaction.MAX_MONEY)) {
|
|
211
|
+
throw new Error("The amount is too high.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const builder = new TransactionBuilder();
|
|
215
|
+
if (data) {
|
|
216
|
+
builder.addData(data);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (token) {
|
|
220
|
+
if (!isValidNexaAddress(token, AddressType.GroupIdAddress)) {
|
|
221
|
+
throw new Error('Invalid Token ID');
|
|
222
|
+
}
|
|
223
|
+
if (Address.getOutputType(toAddr) === 0) {
|
|
224
|
+
throw new Error('Token must be sent to script template address');
|
|
225
|
+
}
|
|
226
|
+
builder.to(toAddr, Transaction.DUST_AMOUNT, token, BigInt(amount))
|
|
227
|
+
} else {
|
|
228
|
+
builder.to(toAddr, amount);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return builder;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async populateNexaInputsAndChange(txBuilder: TransactionBuilder, from: AddressKey[], toChange: string, options: TxOptions): Promise<Map<string, PrivateKey>> {
|
|
235
|
+
const allKeys = from.filter(k => BigInt(k.balance.confirmed) + BigInt(k.balance.unconfirmed) > 0n);
|
|
236
|
+
if (isNullOrEmpty(allKeys)) {
|
|
237
|
+
throw new Error("Not enough Nexa balance.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const usedKeys = new Map<string, PrivateKey>();
|
|
241
|
+
const origAmount = options.isConsolidate ? 0 : Number(txBuilder.transaction.outputs.find(out => out.value > 0n)!.value);
|
|
242
|
+
|
|
243
|
+
for (const key of allKeys) {
|
|
244
|
+
const utxos = await this.rostrumService.getNexaUtxos(key.address);
|
|
245
|
+
for (const utxo of utxos) {
|
|
246
|
+
const input: UTXO = {
|
|
247
|
+
outpoint: utxo.outpoint_hash,
|
|
248
|
+
address: key.address,
|
|
249
|
+
satoshis: utxo.value,
|
|
250
|
+
templateData: options.templateData
|
|
251
|
+
}
|
|
252
|
+
txBuilder.from(input);
|
|
253
|
+
|
|
254
|
+
if (!usedKeys.has(key.address)) {
|
|
255
|
+
const hdkey = this.keyManager.getKey(key.keyPath);
|
|
256
|
+
usedKeys.set(key.address, hdkey.privateKey);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (options.isConsolidate) {
|
|
260
|
+
txBuilder.change(toChange);
|
|
261
|
+
if (txBuilder.transaction.inputs.length > this.MAX_INPUTS_OUTPUTS) {
|
|
262
|
+
return usedKeys;
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
const tx = txBuilder.transaction;
|
|
266
|
+
if (tx.inputs.length > this.MAX_INPUTS_OUTPUTS) {
|
|
267
|
+
throw new Error("Too many inputs. Consider consolidate transactions or reduce the send amount.");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const unspent = tx.getUnspentValue();
|
|
271
|
+
if (unspent < 0n) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (unspent == 0n && options.feeFromAmount) {
|
|
276
|
+
const txFee = tx.estimateRequiredFee();
|
|
277
|
+
tx.updateOutputAmount(0, origAmount - txFee);
|
|
278
|
+
return usedKeys;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
txBuilder.change(toChange);
|
|
282
|
+
if (options.feeFromAmount) {
|
|
283
|
+
const hasChange = tx.getChangeOutput();
|
|
284
|
+
let txFee = tx.estimateRequiredFee();
|
|
285
|
+
tx.updateOutputAmount(0, origAmount - txFee);
|
|
286
|
+
|
|
287
|
+
// edge case where change added after update
|
|
288
|
+
if (!hasChange && tx.getChangeOutput()) {
|
|
289
|
+
txFee = tx.estimateRequiredFee();
|
|
290
|
+
tx.updateOutputAmount(0, origAmount - txFee);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// check again after change output manipulation
|
|
295
|
+
if (tx.getUnspentValue() < tx.estimateRequiredFee()) {
|
|
296
|
+
// try to add more utxos to satisfy the minimum fee
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return usedKeys;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (options.isConsolidate) {
|
|
306
|
+
if (usedKeys.size > 0) {
|
|
307
|
+
return usedKeys;
|
|
308
|
+
}
|
|
309
|
+
throw new Error("Not enough Nexa balance.");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const err = {
|
|
313
|
+
errorMsg: "Not enough Nexa balance.",
|
|
314
|
+
amount: UnitUtils.formatNEXA(txBuilder.transaction.outputs[0].value),
|
|
315
|
+
fee: UnitUtils.formatNEXA(txBuilder.transaction.estimateRequiredFee())
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new Error(JSON.stringify(err));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async populateTokenInputsAndChange(txBuilder: TransactionBuilder, from: AddressKey[], toChange: string, token: string, outTokenAmount: bigint): Promise<Map<string, PrivateKey>> {
|
|
322
|
+
const tokenHex = tokenIdToHex(token);
|
|
323
|
+
const allKeys = from.filter(k => Object.keys(k.tokensBalance).includes(tokenHex));
|
|
324
|
+
|
|
325
|
+
if (isNullOrEmpty(allKeys)) {
|
|
326
|
+
throw new Error("Not enough token balance.");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const usedKeys = new Map<string, PrivateKey>();
|
|
330
|
+
let inTokenAmount = 0n;
|
|
331
|
+
|
|
332
|
+
for (const key of allKeys) {
|
|
333
|
+
const utxos = await this.rostrumService.getTokenUtxos(key.address, token);
|
|
334
|
+
for (const utxo of utxos) {
|
|
335
|
+
if (BigInt(utxo.token_amount) < 0n) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
txBuilder.from({
|
|
339
|
+
outpoint: utxo.outpoint_hash,
|
|
340
|
+
address: key.address,
|
|
341
|
+
satoshis: utxo.value,
|
|
342
|
+
groupId: utxo.group,
|
|
343
|
+
groupAmount: BigInt(utxo.token_amount),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
inTokenAmount = inTokenAmount + BigInt(utxo.token_amount);
|
|
347
|
+
if (!usedKeys.has(key.address)) {
|
|
348
|
+
const hdkey = this.keyManager.getKey(key.keyPath);
|
|
349
|
+
usedKeys.set(key.address, hdkey.privateKey);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (inTokenAmount > MAX_INT64) {
|
|
353
|
+
throw new Error("Token inputs exceeded max amount. Consider sending in small chunks");
|
|
354
|
+
}
|
|
355
|
+
if (txBuilder.transaction.inputs.length > this.MAX_INPUTS_OUTPUTS) {
|
|
356
|
+
throw new Error("Too many inputs. Consider consolidating transactions or reduce the send amount.");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (inTokenAmount == outTokenAmount) {
|
|
360
|
+
return usedKeys;
|
|
361
|
+
}
|
|
362
|
+
if (inTokenAmount > outTokenAmount) {
|
|
363
|
+
// change
|
|
364
|
+
txBuilder.to(toChange, Transaction.DUST_AMOUNT, token, inTokenAmount - outTokenAmount);
|
|
365
|
+
return usedKeys;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
throw new Error("Not enough token balance");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async finalizeTransaction(tx: TransactionBuilder, privKeys: PrivateKey[]): Promise<Transaction> {
|
|
374
|
+
const tip = await this.rostrumService.getBlockTip();
|
|
375
|
+
return tx.lockUntilBlockHeight(tip.height).sign(privKeys).build();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
public printTransactionJson(tx?: Transaction | null): string {
|
|
379
|
+
if (!tx) {
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
const obj = {
|
|
383
|
+
...tx.toObject(),
|
|
384
|
+
hex: tx.toString(),
|
|
385
|
+
}
|
|
386
|
+
return JSON.stringify(obj, null, 2)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Input, PrivateKey} from "libnexa-ts";
|
|
2
|
+
import { BufferUtils, Hash, Opcode, Output, OutputSighashType, Script, ScriptFactory, SighashType, Transaction, TxSigner } from "libnexa-ts";
|
|
3
|
+
import type { KeyManager } from "./key";
|
|
4
|
+
import type { RostrumService } from "./rostrum";
|
|
5
|
+
import type { AddressKey, AssetMovement, MovementDetails, TransactionDetails } from "../types";
|
|
6
|
+
import { tokenAmountToAssetAmount, tokenIdToHex } from "../utils";
|
|
7
|
+
import type { AssetService } from "./asset";
|
|
8
|
+
|
|
9
|
+
interface TransactionContext {
|
|
10
|
+
address: string;
|
|
11
|
+
privateKey: PrivateKey;
|
|
12
|
+
txDetails: TransactionDetails;
|
|
13
|
+
myInputs: Map<number, AssetMovement>;
|
|
14
|
+
myOutputs: Map<number, AssetMovement>;
|
|
15
|
+
involvedAssets: Set<string>;
|
|
16
|
+
inputsToSign: Set<number>;
|
|
17
|
+
coveredOutputs: Set<number>;
|
|
18
|
+
allMyOutputsCovered: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TransactionTransformer {
|
|
22
|
+
|
|
23
|
+
private readonly rostrumService: RostrumService;
|
|
24
|
+
private readonly keyManager: KeyManager;
|
|
25
|
+
private readonly assetService: AssetService;
|
|
26
|
+
|
|
27
|
+
public constructor(rostrumService: RostrumService, keyManager: KeyManager, assetService: AssetService) {
|
|
28
|
+
this.rostrumService = rostrumService;
|
|
29
|
+
this.keyManager = keyManager;
|
|
30
|
+
this.assetService = assetService;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async transformRawTransaction(hex: string, addressKey: AddressKey): Promise<TransactionDetails> {
|
|
34
|
+
const context = this.createContext(hex, addressKey);
|
|
35
|
+
|
|
36
|
+
await this.processInputs(context);
|
|
37
|
+
this.processOutputs(context);
|
|
38
|
+
this.signInputs(context);
|
|
39
|
+
await this.processAssetMovements(context);
|
|
40
|
+
|
|
41
|
+
return context.txDetails;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private createContext(hex: string, addressKey: AddressKey): TransactionContext {
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
address: addressKey.address,
|
|
48
|
+
privateKey: this.keyManager.getKey(addressKey.keyPath).privateKey,
|
|
49
|
+
txDetails: {
|
|
50
|
+
tx: new Transaction(hex),
|
|
51
|
+
send: [],
|
|
52
|
+
receive: []
|
|
53
|
+
},
|
|
54
|
+
myInputs: new Map<number, AssetMovement>(),
|
|
55
|
+
myOutputs: new Map<number, AssetMovement>(),
|
|
56
|
+
involvedAssets: new Set<string>(),
|
|
57
|
+
inputsToSign: new Set<number>(),
|
|
58
|
+
coveredOutputs: new Set<number>(),
|
|
59
|
+
allMyOutputsCovered: false,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error("Invalid transaction format.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async processInputs(context: TransactionContext): Promise<void> {
|
|
67
|
+
const promises = [];
|
|
68
|
+
for (let i = 0; i < context.txDetails.tx.inputs.length; i++) {
|
|
69
|
+
const p = this.processInput(context, i);
|
|
70
|
+
promises.push(p);
|
|
71
|
+
}
|
|
72
|
+
await Promise.all(promises);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async processInput(context: TransactionContext, index: number): Promise<void> {
|
|
76
|
+
const input = context.txDetails.tx.inputs[index];
|
|
77
|
+
const utxo = await this.rostrumService.getUtxo(BufferUtils.bufferToHex(input.outpoint));
|
|
78
|
+
if (utxo.status == "spent") {
|
|
79
|
+
throw new Error("Input UTXO is already spent.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
input.output = new Output(utxo.amount, utxo.scriptpubkey);
|
|
83
|
+
if (input.output.address == context.address) {
|
|
84
|
+
const assetId = utxo.token_id_hex || "NEXA";
|
|
85
|
+
context.myInputs.set(index, {
|
|
86
|
+
address: context.address,
|
|
87
|
+
nexaAmount: utxo.amount.toString(),
|
|
88
|
+
assetId: assetId,
|
|
89
|
+
assetAmount: tokenAmountToAssetAmount(utxo.group_quantity) || utxo.amount.toString()
|
|
90
|
+
});
|
|
91
|
+
context.involvedAssets.add(assetId);
|
|
92
|
+
|
|
93
|
+
if (input.scriptSig.isEmpty()) {
|
|
94
|
+
context.inputsToSign.add(index);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (input.scriptSig.findPlaceholder() > 0) {
|
|
99
|
+
context.inputsToSign.add(index);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private processOutputs(context: TransactionContext): void {
|
|
104
|
+
for (let i = 0; i < context.txDetails.tx.outputs.length; i++) {
|
|
105
|
+
const output = context.txDetails.tx.outputs[i].toObject();
|
|
106
|
+
if (output.address !== context.address) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const assetId = output.groupId ? tokenIdToHex(output.groupId) : "NEXA";
|
|
111
|
+
const movement = {
|
|
112
|
+
address: output.address,
|
|
113
|
+
nexaAmount: output.value.toString(),
|
|
114
|
+
assetId: assetId,
|
|
115
|
+
assetAmount: tokenAmountToAssetAmount(output.groupAmount) || output.value.toString()
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
context.involvedAssets.add(assetId);
|
|
119
|
+
context.myOutputs.set(i, movement);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private signInputs(context: TransactionContext): void {
|
|
124
|
+
if (context.inputsToSign.size == 0) {
|
|
125
|
+
throw new Error("No inputs to sign.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const index of context.inputsToSign) {
|
|
129
|
+
const input = context.txDetails.tx.inputs[index];
|
|
130
|
+
const subscript = this.validateAndGetSubscript(input);
|
|
131
|
+
|
|
132
|
+
if (input.scriptSig.isEmpty()) { // only p2pkt can be empty
|
|
133
|
+
context.allMyOutputsCovered = true;
|
|
134
|
+
const txSig = TxSigner.sign(context.txDetails.tx, index, SighashType.ALL, subscript, context.privateKey).toTxFormat();
|
|
135
|
+
const constraint = Script.empty().add(context.privateKey.publicKey.toBuffer());
|
|
136
|
+
input.scriptSig = ScriptFactory.buildScriptTemplateIn(undefined, constraint, Script.empty().add(txSig))
|
|
137
|
+
} else {
|
|
138
|
+
const placeholderIndex = input.scriptSig.findPlaceholder();
|
|
139
|
+
const placeholder = input.scriptSig.chunks[placeholderIndex];
|
|
140
|
+
const sigHashBuf = placeholder.buf!.subarray(64);
|
|
141
|
+
const sigHashType = SighashType.fromBuffer(sigHashBuf);
|
|
142
|
+
this.processSignatureCoverage(context, sigHashType);
|
|
143
|
+
|
|
144
|
+
const txSig = TxSigner.sign(context.txDetails.tx, index, sigHashType, subscript, context.privateKey).toTxFormat(sigHashBuf);
|
|
145
|
+
input.scriptSig.replaceChunk(placeholderIndex, Script.empty().add(txSig));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!context.allMyOutputsCovered) {
|
|
150
|
+
throw new Error("Some of interested outputs are not covered by signatures.");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private validateAndGetSubscript(input: Input): Script {
|
|
155
|
+
if (input.output!.scriptPubKey.isPublicKeyTemplateOut()) {
|
|
156
|
+
if (input.scriptSig.isEmpty() || input.scriptSig.isPublicKeyTemplateIn()) {
|
|
157
|
+
return Script.empty().add(Opcode.OP_FROMALTSTACK).add(Opcode.OP_CHECKSIGVERIFY);
|
|
158
|
+
}
|
|
159
|
+
throw new Error("Invalid input script type.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (input.output!.scriptPubKey.isScriptTemplateOut()) {
|
|
163
|
+
if (!input.scriptSig.isScriptTemplateIn()) {
|
|
164
|
+
throw new Error("Unsupported input script type.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const templateHash = input.output!.scriptPubKey.getTemplateHash() as Uint8Array;
|
|
168
|
+
const templateBuf = input.scriptSig.chunks[0].buf!;
|
|
169
|
+
if (BufferUtils.equals(templateHash, Hash.sha256ripemd160(templateBuf)) || BufferUtils.equals(templateHash, Hash.sha256sha256(templateBuf))) {
|
|
170
|
+
return Script.fromBuffer(templateBuf);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error("Invalid input script template.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error("Unsupported prevout script type.");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private processSignatureCoverage(context: TransactionContext, sigHashType: SighashType): void {
|
|
180
|
+
if (context.allMyOutputsCovered) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sigHashType.outType == OutputSighashType.TWO) {
|
|
185
|
+
context.coveredOutputs.add(sigHashType.outData[0]);
|
|
186
|
+
context.coveredOutputs.add(sigHashType.outData[1]);
|
|
187
|
+
this.checkSignatureCoverage(context);
|
|
188
|
+
} else if (sigHashType.outType == OutputSighashType.FIRSTN) {
|
|
189
|
+
const n = sigHashType.outData[0];
|
|
190
|
+
for (let i = 0; i < n; i++) {
|
|
191
|
+
context.coveredOutputs.add(i);
|
|
192
|
+
}
|
|
193
|
+
this.checkSignatureCoverage(context);
|
|
194
|
+
} else {
|
|
195
|
+
context.allMyOutputsCovered = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private checkSignatureCoverage(context: TransactionContext): void {
|
|
200
|
+
if (context.allMyOutputsCovered) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const i of context.myOutputs.keys()) {
|
|
205
|
+
if (!context.coveredOutputs.has(i)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
context.allMyOutputsCovered = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async processAssetMovements(context: TransactionContext): Promise<void> {
|
|
213
|
+
const myInputs = Array.from(context.myInputs.values());
|
|
214
|
+
const myOutputs = Array.from(context.myOutputs.values());
|
|
215
|
+
|
|
216
|
+
const promises = [];
|
|
217
|
+
for (const assetId of context.involvedAssets) {
|
|
218
|
+
const p = this.processAssetMovement(context, assetId, myInputs, myOutputs);
|
|
219
|
+
promises.push(p);
|
|
220
|
+
}
|
|
221
|
+
await Promise.all(promises);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async processAssetMovement(context: TransactionContext, assetId: string, myInputs: AssetMovement[], myOutputs: AssetMovement[]): Promise<void> {
|
|
225
|
+
const sentAmount = myInputs
|
|
226
|
+
.reduce((sum, m) => m.assetId === assetId ? sum + BigInt(m.assetAmount) : sum, 0n);
|
|
227
|
+
|
|
228
|
+
const receivedAmount = myOutputs
|
|
229
|
+
.reduce((sum, m) => m.assetId === assetId ? sum + BigInt(m.assetAmount) : sum, 0n);
|
|
230
|
+
|
|
231
|
+
if (sentAmount > 0n || receivedAmount > 0n) {
|
|
232
|
+
const movement: MovementDetails = { amount: receivedAmount - sentAmount };
|
|
233
|
+
if (assetId !== "NEXA") {
|
|
234
|
+
movement.asset = await this.assetService.getAssetInfo(assetId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (movement.amount > 0n) {
|
|
238
|
+
context.txDetails.receive.push(movement);
|
|
239
|
+
} else if (movement.amount < 0n) {
|
|
240
|
+
context.txDetails.send.push(movement);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|