diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index 5f5ff41..6134c84 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -4190,6 +4190,8 @@ class Signer { } } +export { Transaction } from './transaction'; + export { Client, Signer, diff --git a/packages/sdk/src/transaction.ts b/packages/sdk/src/transaction.ts index c204782..036c9c9 100644 --- a/packages/sdk/src/transaction.ts +++ b/packages/sdk/src/transaction.ts @@ -65,23 +65,31 @@ export class Transaction { private utxos: Utxo[] private transactionId: string private hexRepresentation: string - private binRepresentation: Uint8Array + private binRepresentation: { inputs: Uint8Array[]; outputs: Uint8Array[]; transactionsize: number } | null private jsonRepresentation: TransactionJSONRepresentation private currentBlockHeight: number private client: Client private network: 'mainnet' | 'testnet' private changeAddress: string - constructor({ client }: { client?: Client }) { + constructor({ + client, + network, + currentBlockHeight, + }: { + client?: Client; + network?: 'mainnet' | 'testnet'; + currentBlockHeight?: number | string | bigint; + } = {}) { this.inputs = []; this.outputs = []; this.utxos = []; this.transactionId = ''; this.hexRepresentation = ''; - this.binRepresentation = new Uint8Array([]); - this.currentBlockHeight = 0; + this.binRepresentation = null; + this.currentBlockHeight = currentBlockHeight !== undefined ? Number(currentBlockHeight) : 0; this.jsonRepresentation = {}; - this.network = 'testnet'; + this.network = network ?? 'testnet'; this.fee = BigInt(0); this.changeAddress = ''; @@ -95,6 +103,16 @@ export class Transaction { return this; } + setNetwork(network: 'mainnet' | 'testnet') { + this.network = network; + return this; + } + + setCurrentBlockHeight(height: number | string | bigint) { + this.currentBlockHeight = Number(height); + return this; + } + addInput(input: Input) { this.inputs.push(input); return this; @@ -122,67 +140,129 @@ export class Transaction { return this.transactionId; } + // Signer-compatibility getters (match the shape used by Signer.sign()) + get JSONRepresentation(): TransactionJSONRepresentation { + return this.jsonRepresentation; + } + get BINRepresentation() { + return this.binRepresentation; + } + get HEXRepresentation_unsigned(): string { + return this.hexRepresentation; + } + get transaction_id(): string { + return this.transactionId; + } + build() { - if(!this.client && !this.utxos.length) { + if (!this.client && !this.utxos.length) { throw new Error('Client or UTXOs are required to build transaction'); } - if(!this.client && !this.changeAddress) { + if (!this.client && !this.changeAddress) { throw new Error('Client or Change Address are required to build transaction'); } - const input_amount_coin_req = this.outputs.reduce((acc, item) => { - if (item.value.type === 'Coin') { - return acc + BigInt(item.value.amount.atoms); + const declaredOutputs: Output[] = [...this.outputs]; + + // Sum coin and per-token requirements from user-declared outputs. + let input_amount_coin_req = 0n; + const token_reqs = new Map(); + for (const out of declaredOutputs) { + const val = (out as any)?.value; + if (!val) continue; + if (val.type === 'Coin') { + input_amount_coin_req += BigInt(val.amount.atoms); + } else if (val.type === 'TokenV1') { + token_reqs.set( + val.token_id, + (token_reqs.get(val.token_id) ?? 0n) + BigInt(val.amount.atoms), + ); } - return acc; - }, 0n); + } - let preciseFee = BigInt(0); - let previousFee = BigInt(-1); - const MAX_ATTEMPTS = 10; - let attempts = 0; + const networkId = this.network === 'mainnet' ? 0 : 1; - while (attempts < MAX_ATTEMPTS) { - attempts++; + let preciseFee = 0n; + let previousFee = -1n; + const MAX_ATTEMPTS = 10; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const totalFee = preciseFee; - const input_amount_coin_req_w_fee = input_amount_coin_req + totalFee; - - this.inputs = this.selectUTXOs(this.utxos, input_amount_coin_req_w_fee, null); - - const totalInputValueCoin = this.inputs.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.decimal), 0n); + const coin_req_w_fee = input_amount_coin_req + totalFee; + + const coinInputs = this.selectUTXOs(this.utxos, coin_req_w_fee, null); + const totalCoinIn = coinInputs.reduce( + (acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), + 0n, + ); + if (totalCoinIn < coin_req_w_fee) { + throw new Error('Not enough coin UTXOs'); + } - const changeAmountCoin = totalInputValueCoin - input_amount_coin_req_w_fee; + const tokenInputsAll: UtxoInput[] = []; + const tokenChanges: Array<{ token_id: string; amount: bigint }> = []; + for (const [token_id, req] of token_reqs.entries()) { + const tInputs = this.selectUTXOs(this.utxos, req, token_id); + const totalIn = tInputs.reduce( + (acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), + 0n, + ); + if (totalIn < req) { + throw new Error(`Not enough token UTXOs for ${token_id}`); + } + tokenInputsAll.push(...tInputs); + if (totalIn > req) { + tokenChanges.push({ token_id, amount: totalIn - req }); + } + } - if (changeAmountCoin > 0n) { - this.outputs.push({ + const finalOutputs: Output[] = [...declaredOutputs]; + const changeCoin = totalCoinIn - coin_req_w_fee; + if (changeCoin > 0n) { + finalOutputs.push({ type: 'Transfer', value: { type: 'Coin', amount: { - atoms: changeAmountCoin.toString(), - decimal: atomsToDecimal(changeAmountCoin.toString(), 11).toString(), + atoms: changeCoin.toString(), + decimal: atomsToDecimal(changeCoin.toString(), 11).toString(), + }, + }, + destination: this.changeAddress, + }); + } + for (const c of tokenChanges) { + finalOutputs.push({ + type: 'Transfer', + value: { + type: 'TokenV1', + token_id: c.token_id, + amount: { + atoms: c.amount.toString(), + decimal: c.amount.toString(), }, }, destination: this.changeAddress, }); } + const finalInputs: Input[] = [...coinInputs, ...tokenInputsAll]; + const JSONRepresentation: TransactionJSONRepresentation = { - inputs: this.inputs, - outputs: this.outputs, + inputs: finalInputs, + outputs: finalOutputs, fee: { atoms: totalFee.toString(), decimal: atomsToDecimal(totalFee.toString(), 11).toString(), }, - id: 'to_be_filled_in' + id: 'to_be_filled_in', }; - const BINRepresentation = this.getTransactionBINrepresentation(JSONRepresentation, this.network === 'mainnet' ? 0 : 1); + const BINRepresentation = this.getTransactionBINrepresentation(JSONRepresentation, networkId); - const transaction_size_in_bytes = BigInt(Math.ceil(BINRepresentation.transactionsize)); - const fee_amount_per_kb = BigInt('100000000000'); // TODO: Get the current feerate from the network - const nextPreciseFee = (fee_amount_per_kb * transaction_size_in_bytes + BigInt(999)) / BigInt(1000); + const tx_size = BigInt(Math.ceil(BINRepresentation.transactionsize)); + const fee_per_kb = 100_000_000_000n; // TODO: fetch live feerate + const nextPreciseFee = (fee_per_kb * tx_size + 999n) / 1000n; if (nextPreciseFee === preciseFee || nextPreciseFee === previousFee) { const transaction = encode_transaction( @@ -190,60 +270,38 @@ export class Transaction { mergeUint8Arrays(BINRepresentation.outputs), BigInt(0), ); - const transaction_id = get_transaction_id(transaction, true); - this.transactionId = transaction_id; - // if (finalOutputs.some((output) => output.type === 'IssueNft')) { - // const token_id = get_token_id( - // mergeUint8Arrays(BINRepresentation.inputs), - // this.network === 'mainnet' ? Network.Mainnet : Network.Testnet, - // ); - // const index = finalOutputs.findIndex((output) => output.type === 'IssueNft'); - // const output = finalOutputs[index] as IssueNftOutput; - // finalOutputs[index] = { - // ...output, - // token_id, - // }; - // } - // - // const HEXRepresentation_unsigned = transaction.reduce( - // (acc, byte) => acc + byte.toString(16).padStart(2, '0'), - // '', - // ); - // - // return { - // JSONRepresentation: { - // ...JSONRepresentation, - // id: transaction_id, - // }, - // BINRepresentation, - // HEXRepresentation_unsigned, - // transaction_id, - // }; + this.fee = totalFee; + this.transactionId = transaction_id; + this.binRepresentation = BINRepresentation; + this.hexRepresentation = transaction.reduce( + (acc, byte) => acc + byte.toString(16).padStart(2, '0'), + '', + ); + this.jsonRepresentation = { ...JSONRepresentation, id: transaction_id }; + return this; } previousFee = preciseFee; preciseFee = nextPreciseFee; - this.fee = preciseFee; } - return this; + throw new Error('Failed to build transaction after maximum attempts'); } hex() { return this.hexRepresentation; } - json() { + json(): TransactionJSONRepresentation { + return this.jsonRepresentation; + } + + getFee() { return { - inputs: this.inputs, - outputs: this.outputs, - fee: { - atoms: this.fee.toString(), - decimal: atomsToDecimal(this.fee.toString(), 11).toString(), - }, - id: this.transactionId, + atoms: this.fee.toString(), + decimal: atomsToDecimal(this.fee.toString(), 11).toString(), }; } @@ -492,7 +550,10 @@ export class Transaction { const { destination: address, token_id } = output; - const chainTip = '200000'; // TODO unhardcode + if (!this.currentBlockHeight) { + throw new Error('currentBlockHeight is required for IssueNft — call setCurrentBlockHeight(...)'); + } + const chainTip = this.currentBlockHeight; return encode_output_issue_nft( token_id as string, @@ -512,7 +573,10 @@ export class Transaction { if (output.type === 'IssueFungibleToken') { const { authority, is_freezable, metadata_uri, number_of_decimals, token_ticker, total_supply } = output; - const chainTip = '200000'; // TODO: unhardcode height + if (!this.currentBlockHeight) { + throw new Error('currentBlockHeight is required for IssueFungibleToken — call setCurrentBlockHeight(...)'); + } + const chainTip = this.currentBlockHeight; const is_token_freezable = is_freezable ? FreezableToken.Yes : FreezableToken.No; @@ -651,6 +715,25 @@ export class Transaction { } } + transferToken(destination: string, amount: string, token_id: string): Output { + return { + type: 'Transfer', + destination, + value: { + type: 'TokenV1', + token_id, + amount: { + atoms: amount, + decimal: amount, + }, + }, + }; + } + + transferNft(destination: string, token_id: string): Output { + return this.transferToken(destination, '1', token_id); + } + // actions stakingWithdraw() { return { diff --git a/packages/sdk/tests/transaction-builder.test.ts b/packages/sdk/tests/transaction-builder.test.ts index 9fd2fe1..3ebe3c9 100644 --- a/packages/sdk/tests/transaction-builder.test.ts +++ b/packages/sdk/tests/transaction-builder.test.ts @@ -2,65 +2,221 @@ import { Transaction, } from '../src/transaction'; -test('single transfer output', () => { - const transaction = new Transaction({ client: undefined }); - - transaction.addOutput(transaction.transfer('tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc', '10')); - transaction.setChangeAddress('tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt'); - transaction.withUTXO({ - outpoint: { - index: 0, - input_type: 'UTXO', - source_id: '5b43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', - source_type: 'Transaction', +const CHANGE_ADDR = 'tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt'; +const RECIPIENT = 'tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc'; +const COIN_UTXO = { + outpoint: { + index: 0, + input_type: 'UTXO', + source_id: '5b43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', + source_type: 'Transaction', + }, + utxo: { + destination: CHANGE_ADDR, + type: 'Transfer', + value: { + amount: { + atoms: '2000000000000', + decimal: '20', + }, + type: 'Coin', }, - utxo: { - destination: 'tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt', - type: 'Transfer', - value: { - amount: { - atoms: '2000000000000', - decimal: '20', - }, - type: 'Coin', + }, +}; + +const TOKEN_ID = 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq'; +const TOKEN_UTXO = { + outpoint: { + index: 1, + input_type: 'UTXO', + source_id: 'aa43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', + source_type: 'Transaction', + }, + utxo: { + destination: CHANGE_ADDR, + type: 'Transfer', + value: { + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { + atoms: '1000', + decimal: '10', }, }, - }); + }, +}; - console.log(transaction.build().json()); +const NFT_TOKEN_ID = 'tmltk1hulyp284e3kc522ta435wyckrqy4j4842perueyge6ctjlp2mpds65mcx8'; +const NFT_UTXO = { + outpoint: { + index: 2, + input_type: 'UTXO', + source_id: 'bb43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', + source_type: 'Transaction', + }, + utxo: { + destination: CHANGE_ADDR, + type: 'IssueNft', + token_id: NFT_TOKEN_ID, + data: { + name: { hex: '', string: 'NFT' }, + ticker: { hex: '', string: 'NFT' }, + description: { hex: '', string: '' }, + media_hash: { hex: '', string: '' }, + media_uri: { hex: '', string: '' }, + icon_uri: { hex: '', string: '' }, + additional_metadata_uri: { hex: '', string: '' }, + creator: null, + }, + }, +}; + +test('single transfer output', () => { + const transaction = new Transaction(); + + transaction.addOutput(transaction.transfer(RECIPIENT, '10')); + transaction.setChangeAddress(CHANGE_ADDR); + transaction.withUTXO(COIN_UTXO); + + transaction.build(); expect(transaction).toBeDefined(); }); test('double transfer output', () => { - const transaction = new Transaction({ client: undefined }); - - transaction.addOutput(transaction.transfer('tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc', '10')); - transaction.addOutput(transaction.transfer('tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc', '12')); - transaction.withUTXO({ - outpoint: { - index: 0, - input_type: 'UTXO', - source_id: '5b43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', - source_type: 'Transaction', - }, - utxo: { - destination: 'tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt', - type: 'Transfer', - value: { - amount: { - atoms: '2000000000000', - decimal: '30', - }, - type: 'Coin', - }, - }, - }); - transaction.setChangeAddress('tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt'); + const transaction = new Transaction(); - const txHex = transaction.build().json(); + transaction.addOutput(transaction.transfer(RECIPIENT, '10')); + transaction.addOutput(transaction.transfer(RECIPIENT, '12')); + transaction.withUTXO(COIN_UTXO); + transaction.setChangeAddress(CHANGE_ADDR); - console.log(JSON.stringify(txHex, null, 2)); + transaction.build(); expect(transaction).toBeDefined(); }); + +test('coin transfer exposes a positive fee on the built transaction', () => { + const transaction = new Transaction() + .setChangeAddress(CHANGE_ADDR) + .withUTXO(COIN_UTXO) + .addOutput(new Transaction().transfer(RECIPIENT, '10')) + .build(); + + const json = transaction.json(); + expect(json.fee).toBeDefined(); + expect(json.fee).toHaveProperty('atoms'); + expect(json.fee).toHaveProperty('decimal'); + // @ts-ignore + expect(BigInt(json.fee!.atoms)).toBeGreaterThan(0n); + + const fee = transaction.getFee(); + expect(fee.atoms).toBe(json.fee!.atoms); + expect(fee.decimal).toBe(json.fee!.decimal); + + expect(typeof json.id).toBe('string'); + expect(json.id.length).toBeGreaterThan(0); + expect(transaction.hex().length).toBeGreaterThan(0); +}); + +test('token transfer produces token change and a fee', () => { + const tx = new Transaction() + .setChangeAddress(CHANGE_ADDR) + .withUTXO([COIN_UTXO, TOKEN_UTXO]); + + tx.addOutput(tx.transferToken(RECIPIENT, '400', TOKEN_ID)); + tx.build(); + + const json = tx.json(); + + // fee is exposed and non-zero + // @ts-ignore + expect(BigInt(json.fee!.atoms)).toBeGreaterThan(0n); + + // a coin input was selected (to pay the fee) and at least one token input + const coinInputs = json.inputs.filter( + (i: any) => i.utxo?.value?.type === 'Coin', + ); + const tokenInputs = json.inputs.filter( + (i: any) => i.utxo?.value?.type === 'TokenV1', + ); + expect(coinInputs.length).toBeGreaterThan(0); + expect(tokenInputs.length).toBeGreaterThan(0); + + // token change returns 600 atoms back to the change address + const tokenChange = json.outputs.find( + (o: any) => + o.type === 'Transfer' && + o.destination === CHANGE_ADDR && + o.value?.type === 'TokenV1' && + o.value?.token_id === TOKEN_ID, + ) as any; + expect(tokenChange).toBeDefined(); + expect(tokenChange.value.amount.atoms).toBe('600'); +}); + +test('network parameter reaches the encoder (mainnet rejects testnet addresses)', () => { + // Baseline: testnet builder with testnet addresses succeeds. + const testnetTx = new Transaction({ network: 'testnet' }) + .setChangeAddress(CHANGE_ADDR) + .withUTXO(COIN_UTXO) + .addOutput(new Transaction().transfer(RECIPIENT, '10')) + .build(); + expect(testnetTx.hex().length).toBeGreaterThan(0); + + // Same fixtures but network flipped to mainnet — the address prefixes no + // longer match the network, so the wasm encoder must reject the build. + // This proves the `network` option is actually forwarded to the encoder. + expect(() => { + new Transaction({ network: 'mainnet' }) + .setChangeAddress(CHANGE_ADDR) + .withUTXO(COIN_UTXO) + .addOutput(new Transaction().transfer(RECIPIENT, '10')) + .build(); + }).toThrow(); +}); + +test('setNetwork overrides the constructor default', () => { + const tx = new Transaction() + .setNetwork('testnet') + .setChangeAddress(CHANGE_ADDR) + .withUTXO(COIN_UTXO) + .addOutput(new Transaction().transfer(RECIPIENT, '10')) + .build(); + + expect(tx.json().fee).toBeDefined(); + expect(tx.hex().length).toBeGreaterThan(0); +}); + +test('nft transfer produces no token change and a fee', () => { + const tx = new Transaction() + .setChangeAddress(CHANGE_ADDR) + .withUTXO([COIN_UTXO, NFT_UTXO]); + + tx.addOutput(tx.transferNft(RECIPIENT, NFT_TOKEN_ID)); + tx.build(); + + const json = tx.json(); + + // @ts-ignore + expect(BigInt(json.fee!.atoms)).toBeGreaterThan(0n); + + // NFT is spent fully — no token change output for NFT_TOKEN_ID + const nftChange = json.outputs.find( + (o: any) => + o.destination === CHANGE_ADDR && + o.value?.type === 'TokenV1' && + o.value?.token_id === NFT_TOKEN_ID, + ); + expect(nftChange).toBeUndefined(); + + // NFT output is present to the recipient + const nftOut = json.outputs.find( + (o: any) => + o.destination === RECIPIENT && + o.value?.type === 'TokenV1' && + o.value?.token_id === NFT_TOKEN_ID, + ) as any; + expect(nftOut).toBeDefined(); + expect(nftOut.value.amount.atoms).toBe('1'); +});