diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7fd8df5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,93 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish (e.g. sdk-v1.1.0)' + required: true + type: string + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write + + steps: + - name: Derive package from tag + run: | + TAG="${{ inputs.tag }}" + case "$TAG" in + sdk-v*) PKG="@mintlayer/sdk" ;; + react-v*) PKG="@mintlayer/react" ;; + wasm-lib-v*) PKG="@mintlayer/wasm-lib" ;; + *) echo "Unknown tag format: $TAG (expected sdk-vX.Y.Z | react-vX.Y.Z | wasm-lib-vX.Y.Z)" >&2; exit 1 ;; + esac + echo "PKG=$PKG" >> "$GITHUB_ENV" + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "PKG_DIR=packages/${PKG#*/}" >> "$GITHUB_ENV" + + - name: Checkout release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + + - name: Verify package version matches tag + run: | + PKG_VERSION="$(node -p "require('./${PKG_DIR}/package.json').version")" + TAG_VERSION="${TAG#*v}" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "Version mismatch: ${PKG_DIR}/package.json is $PKG_VERSION but tag is $TAG" >&2 + exit 1 + fi + echo "Publishing $PKG@$PKG_VERSION from $TAG" + + - name: Install pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + registry-url: https://registry.npmjs.org/ + cache: pnpm + + - name: Install latest npm + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --filter "$PKG" build --if-present + + - name: Test + run: pnpm --filter "$PKG" test --if-present + + - name: Replace workspace: dependencies with concrete versions + shell: node {0} + run: | + const fs = require('fs'); + const path = process.env.PKG_DIR + '/package.json'; + const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); + for (const section of ['dependencies', 'peerDependencies', 'devDependencies', 'optionalDependencies']) { + const deps = pkg[section]; + if (!deps) continue; + for (const [name, spec] of Object.entries(deps)) { + if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue; + const dep = JSON.parse(fs.readFileSync('packages/' + name.split('/')[1] + '/package.json', 'utf8')); + const prefix = spec.slice('workspace:'.length); + deps[name] = prefix === '*' ? '^' + dep.version : prefix.replace(/\*/, dep.version); + } + } + fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); + console.log('Resolved workspace deps for', process.env.PKG); + + - name: Stage publish + working-directory: ${{ env.PKG_DIR }} + run: npm stage publish --provenance diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c4d891a..b557154 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,22 +1,34 @@ { "name": "@mintlayer/sdk", - "version": "1.0.38", + "version": "1.1.0", "description": "", "main": "dist/mintlayer-connect-sdk.js", "types": "dist/mintlayer-connect-sdk.d.ts", + "files": [ + "dist" + ], + "author": "Mintlayer", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/mintlayer/mintlayer-connect-sdk.git" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { "build": "tsc", "serve": "npx http-server . -p 8080", "test": "jest", "test:watch": "jest --watch", + "prepublishOnly": "pnpm run test && pnpm run build", "docs": "typedoc --options typedoc.js" }, "dependencies": { "@mintlayer/wasm-lib": "workspace:*" }, "keywords": [], - "author": "", - "license": "ISC", "devDependencies": { "@types/jest": "^29.5.14", "@types/node":"^20.4.2", diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index 4903529..01d9fa1 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -1,50 +1,23 @@ import initWasm, { - get_transaction_id, nft_issuance_fee, token_supply_change_fee, - get_token_id, fungible_token_issuance_fee, Network, token_freeze_fee, token_change_authority_fee, encode_outpoint_source_id, SourceId, - encode_input_for_utxo, - encode_input_for_conclude_order, - encode_input_for_fill_order, - encode_input_for_mint_tokens, - encode_input_for_unmint_tokens, - encode_input_for_lock_token_supply, - encode_input_for_change_token_authority, - encode_input_for_freeze_token, - encode_input_for_unfreeze_token, - encode_input_for_withdraw_from_delegation, encode_output_transfer, encode_output_token_transfer, - estimate_transaction_size, Amount, encode_lock_until_time, encode_lock_for_block_count, encode_output_token_lock_then_transfer, encode_output_lock_then_transfer, - encode_output_issue_nft, data_deposit_fee, - encode_input_for_change_token_metadata_uri, - TokenUnfreezable, - encode_create_order_output, - encode_output_token_burn, - encode_transaction, - encode_output_coin_burn, - FreezableToken, - TotalSupply, - encode_output_issue_fungible_token, - encode_output_data_deposit, - encode_output_create_delegation, - encode_output_delegate_staking, encode_signed_transaction, encode_witness, SignatureHashType, - encode_output_htlc, extract_htlc_secret, verify_challenge, make_default_account_privkey, @@ -54,82 +27,58 @@ import initWasm, { pubkey_to_pubkeyhash_address, sign_challenge, } from '@mintlayer/wasm-lib'; +import { Transaction, FEE_BLOCK_HEIGHT } from './transaction'; +import { + mergeUint8Arrays, + stringToUint8Array, + hexToUint8Array, + uint8ArrayToHex, + BASE58_ALPHABET, + atomsToDecimal, + decimalsToAtoms, + decimals, +} from './utils'; + +// Public numeric-format helpers — single-sourced in ./utils. +export { atomsToDecimal, decimalsToAtoms, decimals } from './utils'; +import type { AssembledTransactionData, PreparedTransaction } from './transaction'; +import type { + AmountFields, + Value, + UtxoInput, + UtxoEntry, + Input, + Output, + Timelock, + TotalSupplyValue, + TransactionJSONRepresentation, +} from './types/transaction'; -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - -function mergeUint8Arrays(arrays: Uint8Array[]) { - const totalLength = arrays.reduce((sum: number, arr: Uint8Array) => sum + arr.length, 0); - - const result = new Uint8Array(totalLength); - - let offset = 0; - for (const arr of arrays) { - result.set(arr, offset); - offset += arr.length; - } - - return result; -} - -function stringToUint8Array(str: string): Uint8Array { - return new TextEncoder().encode(str); -} - -function hexToUint8Array(hex: any) { - if (hex.length % 2 !== 0) { - throw new Error("Invalid hex string"); - } - - const array = new Uint8Array(hex.length / 2); - for (let i = 0; i < array.length; i++) { - array[i] = parseInt(hex.substr(i * 2, 2), 16); - } - - return array; -} - -function uint8ArrayToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); -} - -export function atomsToDecimal(atoms: string | number, decimals: number): string { - const atomsStr = atoms.toString(); - const atomsLength = atomsStr.length; - - if (decimals === 0) { - return atomsStr; - } - - if (atomsLength <= decimals) { - // Pad with leading zeros - const padded = atomsStr.padStart(decimals, '0'); - return '0.' + padded.replace(/0+$/, '') || '0'; - } - - // Insert decimal point - const integerPart = atomsStr.slice(0, atomsLength - decimals); - const fractionalPart = atomsStr.slice(atomsLength - decimals).replace(/0+$/, ''); - - return fractionalPart === '' ? integerPart : `${integerPart}.${fractionalPart}`; -} +/** + * SDK-level sanity caps for developer-forged raw transactions (generous bounds, + * well above consensus limits, to fail fast on obviously invalid input). + */ +const MAX_RAW_OUTPUTS = 100; +const MAX_RAW_TICKER_LENGTH = 32; +const MAX_RAW_URI_LENGTH = 512; +const MAX_RAW_NFT_NAME_LENGTH = 128; +const MAX_RAW_NFT_DESCRIPTION_LENGTH = 1024; +const MAX_RAW_HASH_LENGTH = 128; +const MAX_RAW_DATA_DEPOSIT_LENGTH = 4096; +const MAX_RAW_CREATOR_LENGTH = 128; -export function decimalsToAtoms(value: string | number, decimals: number): bigint { - const v = Number(value); - const [intPart, fracPart = ""] = v.toFixed(decimals).split("."); - const paddedFrac = (fracPart + "0".repeat(decimals)).slice(0, decimals); - const full = intPart + paddedFrac; - return BigInt(full); -} +/** + * C0/C1 control characters and Unicode bidi marks, stripped from display + * strings so they cannot hide direction/layout tricks in wallets and explorers. + */ +const DISPLAY_STRING_SANITIZE_RE = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g; -export function decimals(value: string | number, decimals: number): string { - const v = Number(value); - if (isNaN(v)) return '0'; - return v - .toFixed(decimals) - .replace(/\.?0+$/, ''); -} +/** + * Block height used for fee estimation, token id derivation, order input + * encoding and witness signing, shared by the Client and the Signer. + * TODO: Get the current block height from the API provider. + */ +export { FEE_BLOCK_HEIGHT } from './transaction'; type Address = { addressesByChain: { @@ -163,10 +112,7 @@ export class MintlayerApiProvider implements ApiProvider { private readonly baseUrl: string; private readonly batchUrl: string; - constructor( - baseUrl: string, - batchUrl: string, - ) { + constructor(baseUrl: string, batchUrl: string) { this.baseUrl = baseUrl.replace(/\/$/, ''); this.batchUrl = batchUrl.replace(/\/$/, ''); } @@ -226,10 +172,7 @@ export class MintlayerApiProvider implements ApiProvider { async broadcastTransaction(tx: string | { hex: string; json: any }): Promise { const response = await fetch(`${this.baseUrl}/transaction`, { method: 'POST', - headers: - typeof tx === 'string' - ? { 'Content-Type': 'text/plain' } - : { 'Content-Type': 'application/json' }, + headers: typeof tx === 'string' ? { 'Content-Type': 'text/plain' } : { 'Content-Type': 'application/json' }, body: typeof tx === 'string' ? tx : JSON.stringify({ transaction: tx.hex, json: tx.json }), }); if (!response.ok) { @@ -510,348 +453,256 @@ type CreateHtlcArgs = { refund_timelock: Timelock; }; -type AmountFields = { - atoms: string; - decimal: string; -}; - -type Coin = { - type: 'Coin'; - amount: AmountFields; -}; - -type Token = { - type: 'TokenV1'; - token_id: string; - amount: AmountFields; -}; - -type Value = Coin | Token; - type SignedTransaction = string; type SignedIntentTransaction = { transactionHex: string; intentEncode: string; -} - -interface Outpoint { - id: string; - index: number; -} - -interface UtxoOutpoint { - index: number; - source_type: SourceId; - source_id: string; -} - -interface UtxoEntry { - outpoint: UtxoOutpoint; - utxo: Utxo; -} - -type BaseUtxo = { - value: Value; - destination: string; - token_id?: string; -}; - -type TransferUtxo = BaseUtxo & { - type: 'Transfer'; }; -type HtlcUtxo = BaseUtxo & { - type: 'Htlc'; - htlc: any; -}; - -type LockThenTransferUtxo = BaseUtxo & { - type: 'LockThenTransfer'; - lock: { - type: 'ForBlockCount' | 'UntilTime'; - content: string; - }; -}; - -type IssueNftUtxo = { - type: 'IssueNft'; - value: Value; - destination: string; - token_id?: string; - data: { - name: { hex: string; string: string }; - ticker: { hex: string; string: string }; - description: { hex: string; string: string }; - media_hash: { hex: string; string: string }; - media_uri: { hex: string; string: string }; - icon_uri: { hex: string; string: string }; - additional_metadata_uri: { hex: string; string: string }; - creator: string | null; - }; -}; - -type Utxo = TransferUtxo | LockThenTransferUtxo | IssueNftUtxo | HtlcUtxo; - -type UtxoInput = { - input: { - index: number; - input_type: 'UTXO'; - source_id: string; - source_type: SourceId; - }; - utxo: Utxo; -}; - -type AccountCommandBase = { - input_type: 'AccountCommand'; - nonce: number; - authority: string; - token_id: string; -}; - -type MintTokensInput = { - input: AccountCommandBase & { - command: 'MintTokens'; - amount: AmountFields; - }; - utxo: null; -}; - -type UnmintTokensInput = { - input: AccountCommandBase & { - command: 'UnmintTokens'; - amount: AmountFields; - }; - utxo: null; -}; - -type LockTokenSupplyInput = { - input: AccountCommandBase & { - command: 'LockTokenSupply'; - }; - utxo: null; -}; - -type ChangeTokenAuthorityInput = { - input: AccountCommandBase & { - command: 'ChangeTokenAuthority'; - new_authority: string; - }; - utxo: null; -}; - -type ChangeMetadataUriInput = { - input: AccountCommandBase & { - command: 'ChangeMetadataUri'; - new_metadata_uri: string; - }; - utxo: null; -}; - -type FreezeTokenInput = { - input: AccountCommandBase & { - command: 'FreezeToken'; - is_unfreezable: boolean; - }; - utxo: null; -}; - -type UnfreezeTokenInput = { - input: AccountCommandBase & { - command: 'UnfreezeToken'; - }; - utxo: null; -}; - -type FillOrderInput = { - input: { - input_type: 'AccountCommand'; - command: 'FillOrder'; - order_id: string; - fill_atoms: string; - destination: string; - nonce: string; - }; - utxo: null; -}; - -type ConcludeOrderInput = { - input: { - input_type: 'AccountCommand'; - command: 'ConcludeOrder'; - order_id: string; - destination: string; - nonce: number; - }; - utxo: null; -}; - -type Input = - | UtxoInput - | MintTokensInput - | UnmintTokensInput - | LockTokenSupplyInput - | ChangeTokenAuthorityInput - | ChangeMetadataUriInput - | FreezeTokenInput - | UnfreezeTokenInput - | FillOrderInput - | ConcludeOrderInput - | DelegationWithdrawInput; - -type TransferOutput = { - type: 'Transfer'; - destination: string; - value: Value; -}; - -type LockThenTransferOutput = { - type: 'LockThenTransfer'; - destination: string; - value: Value; - lock: { - type: 'ForBlockCount' | 'UntilTime'; - content: string; - }; -}; - -type CreateDelegationIdOutput = { - type: 'CreateDelegationId'; - destination: string; - pool_id: string; -}; - -type DelegateStakingOutput = { - type: 'DelegateStaking'; - delegation_id: string; - amount: AmountFields; -}; - -type DelegationWithdrawInput = { - input: { - input_type: 'Account'; - account_type: 'DelegationBalance'; - amount: AmountFields; - delegation_id: string; - nonce: number; - }; -}; +/** + * Developer-facing amount. `atoms` must be a non-negative integer (string or + * number) expressed in atoms; `decimal` is the human-readable representation. + */ +export type RawAmount = { atoms: string | number; decimal: string | number }; -type BurnTokenOutput = { - type: 'BurnToken'; - value: Value; -}; +/** + * Developer-facing value. Tokens carry their `token_id`. + */ +export type RawValue = { type: 'Coin'; amount: RawAmount } | { type: 'TokenV1'; token_id: string; amount: RawAmount }; -type DataDepositOutput = { - type: 'DataDeposit'; - data: string; -}; +/** + * Strings are accepted wherever the canonical format requires a `{hex, string}` + * pair (e.g. `metadata_uri`, `token_ticker`, NFT metadata fields). Plain strings + * are hex-encoded automatically. + */ +export type RawStringField = string | { hex: string; string: string }; -type TotalSupplyValue = +/** + * Developer-facing output for {@link Client.buildRawTransaction}. Mirrors the + * canonical output union but accepts plain strings for `{hex, string}` fields + * and plain numbers for lock contents. + */ +export type RawOutput = + | { + type: 'Transfer'; + destination: string; + value: RawValue; + } + | { + type: 'LockThenTransfer'; + destination: string; + value: RawValue; + lock: + | { type: 'ForBlockCount'; content: string | number } + | { type: 'UntilTime'; content: string | number | { timestamp: string | number } }; + } + | { + type: 'BurnToken'; + value: RawValue; + } + | { + type: 'DataDeposit'; + data: string; + } | { - type: 'Unlimited' | 'Lockable'; + type: 'IssueFungibleToken'; + authority: string; + is_freezable: boolean; + metadata_uri: RawStringField; + number_of_decimals: number; + token_ticker: RawStringField; + total_supply: { type: 'Unlimited' | 'Lockable' } | { type: 'Fixed'; amount: RawAmount }; + } + | { + type: 'IssueNft'; + destination: string; + token_id?: string; + creator?: string | null; + data: { + name: RawStringField; + ticker: RawStringField; + description: RawStringField; + media_hash: RawStringField; + media_uri: RawStringField; + icon_uri: RawStringField; + additional_metadata_uri: RawStringField; + }; + } + | { + type: 'CreateOrder'; + ask_balance: RawAmount; + ask_currency: { type: 'Coin' } | { type: 'TokenV1'; token_id: string }; + give_balance: RawAmount; + give_currency: { type: 'Coin' } | { type: 'TokenV1'; token_id: string }; + initially_asked: RawAmount; + initially_given: RawAmount; + conclude_destination: string; } | { - type: 'Fixed'; - amount: AmountFields; + type: 'CreateDelegationId'; + destination: string; + pool_id: string; + } + | { + type: 'DelegateStaking'; + delegation_id: string; + amount: RawAmount; + } + | { + type: 'Htlc'; + value: RawValue; + htlc: { + refund_key: string; + secret_hash: string | { hex: string; string: string | null }; + spend_key: string; + /** + * UntilTime content accepts either the wrapped `{ timestamp }` object + * or a bare scalar timestamp; both normalize to the identical + * canonical `{ timestamp: string }` shape (accepted since the + * round-3 lock/timelock consolidation). + */ + refund_timelock: + | { type: 'UntilTime'; content: { timestamp: string | number } } + | { type: 'ForBlockCount'; content: string | number }; + }; }; -type IssueFungibleTokenOutput = { - type: 'IssueFungibleToken'; - authority: string; - is_freezable: boolean; - metadata_uri: { hex: string; string: string }; - number_of_decimals: number; - token_ticker: { hex: string; string: string }; - total_supply: TotalSupplyValue; -}; - -type Timelock = +/** + * Developer-facing nonce-based account input for {@link Client.buildRawTransaction}. + * + * `nonce` is optional: when omitted it is looked up from the token/order/delegation + * details (`next_nonce`) and incremented sequentially for repeated inputs on the + * same token. Token-command inputs infer `authority` from token details when omitted. + * UTXO inputs are not accepted here — coin/token UTXOs are always selected + * automatically to cover amounts and fees. + */ +export type RawInput = | { - type: 'UntilTime'; - content: { - timestamp: string; - }; -} + input: { + input_type: 'AccountCommand'; + command: 'MintTokens'; + token_id: string; + authority?: string; + amount: RawAmount; + nonce?: number; + }; + } | { - type: 'ForBlockCount'; - content: number; -}; - -type HtlcOutput = { - type: 'Htlc'; - value: { - token_id?: string; - type: 'Coin' | 'TokenV1'; - amount: AmountFields; - } - token_id?: string; - htlc: { - refund_key: string; - secret_hash: { - hex: string; - string: string | null; - }, - spend_key: string; - refund_timelock: Timelock; - } -} - -type IssueNftOutput = { - type: 'IssueNft'; - destination: string; - token_id?: string; - data: { - name: { hex: string; string: string }; - ticker: { hex: string; string: string }; - description: { hex: string; string: string }; - media_hash: { hex: string; string: string }; - media_uri: { hex: string; string: string }; - icon_uri: { hex: string; string: string }; - additional_metadata_uri: { hex: string; string: string }; - creator: string | null; - }; -}; + input: { + input_type: 'AccountCommand'; + command: 'UnmintTokens'; + token_id: string; + authority?: string; + amount: RawAmount; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'LockTokenSupply'; + token_id: string; + authority?: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'FreezeToken'; + token_id: string; + authority?: string; + is_unfreezable: boolean; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'UnfreezeToken'; + token_id: string; + authority?: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'ChangeTokenAuthority'; + token_id: string; + authority?: string; + new_authority: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'ChangeMetadataUri'; + token_id: string; + authority?: string; + new_metadata_uri: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'FillOrder'; + order_id: string; + fill_atoms: string | number; + destination: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'AccountCommand'; + command: 'ConcludeOrder'; + order_id: string; + destination: string; + nonce?: number; + }; + } + | { + input: { + input_type: 'Account'; + account_type: 'DelegationBalance'; + delegation_id: string; + amount: RawAmount; + nonce?: number; + }; + }; -type CreateOrderOutput = { - type: 'CreateOrder'; - ask_balance: { atoms: string | number; decimal: string }; - ask_currency: { type: 'Coin' } | { type: 'TokenV1'; token_id: string }; - give_balance: { atoms: string | number; decimal: string }; - give_currency: { type: 'Coin' } | { type: 'TokenV1'; token_id: string }; - initially_asked: { atoms: string; decimal: string }; - initially_given: { atoms: string; decimal: string }; - conclude_destination: string; +/** + * Arguments for {@link Client.buildRawTransaction} / {@link Client.forgeTransaction}. + */ +export type RawTransactionArgs = { + outputs: RawOutput[]; + inputs?: RawInput[]; + opts?: TransactionOpts; }; -type Output = - | TransferOutput - | LockThenTransferOutput - | BurnTokenOutput - | DataDepositOutput - | IssueFungibleTokenOutput - | IssueNftOutput - | CreateOrderOutput - | CreateDelegationIdOutput - | DelegateStakingOutput - | HtlcOutput; - -export interface TransactionJSONRepresentation { - inputs: Input[]; - outputs: Output[]; - fee?: AmountFields; - id: string; -} +export type { TransactionJSONRepresentation } from './types/transaction'; -interface Transaction { - JSONRepresentation: TransactionJSONRepresentation; - BINRepresentation: Record; - HEXRepresentation_unsigned: string; +/** + * Plain assembled-transaction data — exactly what the wallet bridge receives + * as `txData` and what the Signer consumes. `Transaction.assembleRaw` + * returns this shape directly; `intent`/`htlc` are attached by intent flows. + */ +type AssembledTransaction = AssembledTransactionData & { intent?: string; htlc?: { spend_pubkey: string }; - transaction_id: string; -} +}; + +/** + * Public type alias for the result of the build* methods (buildTransaction, + * buildRawTransaction, buildTransfer, …). Exported under a distinct name + * because `Transaction` refers to the TransactionBuilder class re-exported + * from './transaction'. + */ +export type BuiltTransaction = AssembledTransaction; interface TokenDetails { token_id: string; @@ -1098,8 +949,7 @@ interface ClientOptions { * If `token_id` is provided, the corresponding token will be sent. */ export type TransferArgs = - | { to: string; amount: number; token_id: string } - | { to: string; amount: number; token_id?: undefined }; + { to: string; amount: number; token_id: string } | { to: string; amount: number; token_id?: undefined }; export type TransferNftArgs = { to: string; @@ -1227,6 +1077,9 @@ export type VerifyChallengeArgs = { }; class Client { + /** Placeholder HTLC creation fee in atoms, shared by legacy and raw builders. */ + private static readonly HTLC_FEE_ATOMS = BigInt(1 * Math.pow(10, 11)); // TODO: 0n + private network: 'mainnet' | 'testnet'; private connectedAddresses: { receiving: string[]; @@ -1250,7 +1103,8 @@ class Client { this.publicKeys = { receiving: [], change: [] }; this.isInitialized = false; this.accountProvider = options.accountProvider || new MojitoAccountProvider(); - this.apiProvider = options.apiProvider || new MintlayerApiProvider(this.getDefaultApiServer(), this.getDefaultBatchServer()); + this.apiProvider = + options.apiProvider || new MintlayerApiProvider(this.getDefaultApiServer(), this.getDefaultBatchServer()); } /** @@ -1411,87 +1265,7 @@ class Client { } /** - * Select required UTXOs for the transaction based on provided amount and token ID. - * @param utxos - * @param amount - * @param token_id - * @private - */ - private selectUTXOs(utxos: UtxoEntry[], amount: bigint, token_id: string | null): UtxoInput[] { - const transferableUtxoTypes = ['Transfer', 'LockThenTransfer', 'IssueNft', 'Htlc']; - const filteredUtxos: any[] = utxos // type fix for NFT considering that NFT don't have amount - .map((utxo) => { - if (utxo.utxo.type === 'IssueNft') { - return { - ...utxo, - utxo: { - ...utxo.utxo, - value: { - amount: { - atoms: 1, - decimal: 1, - }, - type: 'TokenV1', - token_id: utxo.utxo.token_id, - }, - }, - }; - } else { - return utxo; - } - }) - .filter((utxo) => transferableUtxoTypes.includes(utxo.utxo.type)) - .filter((utxo) => { - if (utxo.utxo.type === 'IssueNft') { - return utxo.utxo.token_id === token_id; - } else { - if (token_id === null) { - return utxo.utxo.value.type === 'Coin'; - } - if (utxo.utxo.value.type === 'TokenV1') { - return utxo.utxo.value.token_id === token_id; - } - } - }); - - let balance = BigInt(0); - const utxosToSpend: UtxoEntry[] = []; - let lastIndex = 0; - - filteredUtxos.sort((a, b) => { - return Number(BigInt(b.utxo.value.amount.atoms) - BigInt(a.utxo.value.amount.atoms)); - }); - - for (let i = 0; i < filteredUtxos.length; i++) { - lastIndex = i; - const utxoBalance = BigInt(filteredUtxos[i].utxo.value.amount.atoms); - if (balance < amount) { - balance += utxoBalance; - utxosToSpend.push(filteredUtxos[i]); - } else { - break; - } - } - - if (balance === amount) { - if (filteredUtxos[lastIndex + 1]) { - utxosToSpend.push(filteredUtxos[lastIndex + 1]); - } - } - - const transformedInput: UtxoInput[] = utxosToSpend.map((item: UtxoEntry) => ({ - input: { - ...item.outpoint, - input_type: 'UTXO', - }, - utxo: item.utxo, - })); - - return transformedInput; - } - - /** - * Converts a string to a hex string. + * Converts a string to a hex string (UTF-8, zero-padded bytes). * @param str * @private */ @@ -1500,11 +1274,9 @@ class Client { return ''; } - let hex = ''; - for (let i = 0; i < str.length; i++) { - hex += str.charCodeAt(i).toString(16); - } - return hex; + return Array.from(new TextEncoder().encode(str)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); } /** @@ -1793,6 +1565,37 @@ class Client { return totalDelegation; } + /** + * Single source of truth for account-command protocol fees, consumed by both + * the legacy type-based lookup and the raw builder (which use different + * vocabularies: 'MintToken' vs 'MintTokens'). + * @private + */ + private getFeeForCommand( + command: + | 'MintTokens' + | 'UnmintTokens' + | 'LockTokenSupply' + | 'FreezeToken' + | 'UnfreezeToken' + | 'ChangeTokenAuthority' + | 'ChangeMetadataUri', + ): bigint { + switch (command) { + case 'MintTokens': + case 'UnmintTokens': + case 'LockTokenSupply': + return this.protocolFee(token_supply_change_fee); + case 'FreezeToken': + return this.protocolFee(token_freeze_fee); + case 'ChangeTokenAuthority': + return this.protocolFee(token_change_authority_fee); + case 'ChangeMetadataUri': + case 'UnfreezeToken': + return decimalsToAtoms(50, 11); + } + } + /** * Returns the fee for a specific transaction type. * @param {string} type @@ -1800,41 +1603,43 @@ class Client { */ getFeeForType(type: string): bigint { this.ensureInitialized(); - const block_height = 200000n; // TODO: Get the current block height - let fee = Amount.from_atoms('0'); + + const typeToCommand: Partial< + Record< + string, + | 'MintTokens' + | 'UnmintTokens' + | 'LockTokenSupply' + | 'FreezeToken' + | 'UnfreezeToken' + | 'ChangeTokenAuthority' + | 'ChangeMetadataUri' + > + > = { + MintToken: 'MintTokens', + UnmintToken: 'UnmintTokens', + LockTokenSupply: 'LockTokenSupply', + FreezeToken: 'FreezeToken', + UnfreezeToken: 'UnfreezeToken', + ChangeTokenAuthority: 'ChangeTokenAuthority', + ChangeMetadataUri: 'ChangeMetadataUri', + }; + const command = typeToCommand[type]; + if (command) { + return this.getFeeForCommand(command); + } + switch (type) { case 'Transfer': return 0n; case 'BurnToken': return 0n; case 'IssueNft': - fee = nft_issuance_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); + return this.protocolFee(nft_issuance_fee); case 'IssueFungibleToken': - fee = fungible_token_issuance_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'MintToken': - fee = token_supply_change_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'UnmintToken': - fee = token_supply_change_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'LockTokenSupply': - fee = token_supply_change_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'ChangeTokenAuthority': - fee = token_change_authority_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'ChangeMetadataUri': - return decimalsToAtoms(50, 11); - case 'FreezeToken': - fee = token_freeze_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); - case 'UnfreezeToken': - return decimalsToAtoms(50, 11); + return this.protocolFee(fungible_token_issuance_fee); case 'DataDeposit': - fee = data_deposit_fee(block_height, this.network === 'mainnet' ? Network.Mainnet : Network.Testnet); - return BigInt(fee.atoms()); + return this.protocolFee(data_deposit_fee); case 'CreateDelegationId': return 0n; case 'DelegateStaking': @@ -1850,12 +1655,22 @@ class Client { case 'ConcludeOrder': return 0n; case 'Htlc': - return BigInt(1 * Math.pow(10, 11)); // TODO: 0n + return Client.HTLC_FEE_ATOMS; default: throw new Error(`Unknown transaction type: ${type}`); } } + /** + * Computes a protocol fee (in atoms) via a wasm fee function at the block + * height used for fee estimation across the SDK. + * @private + */ + private protocolFee(fee: (block_height: bigint, network: Network) => Amount): bigint { + const block_height = FEE_BLOCK_HEIGHT; + return BigInt(fee(block_height, this.getMLNetwork()).atoms()); + } + private getRequiredInputsOutputs(args: BuildTransactionParams) { const { type, params } = args; let send_token: { token_id: string; number_of_decimals: number } | undefined; @@ -1869,7 +1684,7 @@ class Client { const { token_id, token_details } = params; if (token_details) { - input_amount_token_req += decimalsToAtoms(params.amount!,token_details.number_of_decimals); + input_amount_token_req += decimalsToAtoms(params.amount!, token_details.number_of_decimals); send_token = { token_id, number_of_decimals: token_details.number_of_decimals, @@ -1914,7 +1729,7 @@ class Client { number_of_decimals: token_details.number_of_decimals, }; } else { - input_amount_coin_req += decimalsToAtoms(params.amount!,11); + input_amount_coin_req += decimalsToAtoms(params.amount!, 11); } outputs.push({ @@ -1956,15 +1771,14 @@ class Client { outputs.push({ authority: params.authority, is_freezable: params.is_freezable, - metadata_uri: { - hex: this.stringToHex(params.metadata_uri!), - string: params.metadata_uri, - }, + metadata_uri: this.normalizeRawStringField(params.metadata_uri!, 'params', 'metadata_uri', MAX_RAW_URI_LENGTH), number_of_decimals: params.number_of_decimals, - token_ticker: { - hex: this.stringToHex(params.token_ticker!), - string: params.token_ticker, - }, + token_ticker: this.normalizeRawStringField( + params.token_ticker!, + 'params', + 'token_ticker', + MAX_RAW_TICKER_LENGTH, + ), total_supply, type: 'IssueFungibleToken', }); @@ -1977,34 +1791,23 @@ class Client { token_id: '', data: { creator: params.creator || '', // Todo: Get the creator address - additional_metadata_uri: { - hex: this.stringToHex(params.additional_metadata_uri!), - string: params.additional_metadata_uri, - }, - description: { - hex: this.stringToHex(params.description!), - string: params.description, - }, - icon_uri: { - hex: this.stringToHex(params.icon_uri!), - string: params.icon_uri, - }, - media_hash: { - hex: this.stringToHex(params.media_hash!), - string: params.media_hash, - }, - media_uri: { - hex: this.stringToHex(params.media_uri!), - string: params.media_uri, - }, - name: { - hex: this.stringToHex(params.name!), - string: params.name, - }, - ticker: { - hex: this.stringToHex(params.ticker!), - string: params.ticker, - }, + additional_metadata_uri: this.normalizeRawStringField( + params.additional_metadata_uri!, + 'params', + 'additional_metadata_uri', + MAX_RAW_URI_LENGTH, + ), + description: this.normalizeRawStringField( + params.description!, + 'params', + 'description', + MAX_RAW_NFT_DESCRIPTION_LENGTH, + ), + icon_uri: this.normalizeRawStringField(params.icon_uri!, 'params', 'icon_uri', MAX_RAW_URI_LENGTH), + media_hash: this.normalizeRawStringField(params.media_hash!, 'params', 'media_hash', MAX_RAW_HASH_LENGTH), + media_uri: this.normalizeRawStringField(params.media_uri!, 'params', 'media_uri', MAX_RAW_URI_LENGTH), + name: this.normalizeRawStringField(params.name!, 'params', 'name', MAX_RAW_NFT_NAME_LENGTH), + ticker: this.normalizeRawStringField(params.ticker!, 'params', 'ticker', MAX_RAW_TICKER_LENGTH), }, }); } @@ -2193,7 +1996,7 @@ class Client { decimal: amount.toString(), }, delegation_id, - nonce: delegation_details.next_nonce, + nonce: this.validateNextNonce(delegation_details.next_nonce, 'delegation'), }, }); @@ -2250,14 +2053,14 @@ class Client { initially_asked: { atoms: ask_token_details ? decimalsToAtoms(ask_amount!, ask_token_details.number_of_decimals).toString() - : decimalsToAtoms(ask_amount!, 11).toString(), + : decimalsToAtoms(ask_amount!, 11).toString(), decimal: ask_amount!.toString(), }, give_currency: give_token === 'Coin' ? { type: 'Coin' } : { token_id: give_token, type: 'TokenV1' }, give_balance: { atoms: give_token_details ? decimalsToAtoms(give_amount!, give_token_details.number_of_decimals).toString() - : decimalsToAtoms(give_amount!, 11).toString(), + : decimalsToAtoms(give_amount!, 11).toString(), decimal: give_amount!.toString(), }, initially_given: { @@ -2414,541 +2217,1108 @@ class Client { }, spend_key: params.spend_address, }, - value: - { - ...(token_details - ? { + value: { + ...(token_details + ? { amount: { decimal: params.amount!.toString(), atoms: (params.amount! * Math.pow(10, token_details.number_of_decimals)).toString(), }, } - : { + : { amount: { decimal: params.amount!.toString(), atoms: (params.amount! * Math.pow(10, 11)).toString(), }, }), - ...(token_details - ? { type: 'TokenV1', token_id } - : { + ...(token_details + ? { type: 'TokenV1', token_id } + : { type: 'Coin', }), - }, + }, }); } return { inputs, outputs, send_token, input_amount_coin_req, input_amount_token_req }; } + /** + * Resolves the UTXO set for assembly: explicit `opts.withUTXO` wins, + * otherwise the connected addresses' UTXOs are fetched from the API. + * @private + */ + private async getAssembleUtxos(opts?: TransactionOpts): Promise { + if (opts?.withUTXO) { + return opts.withUTXO; + } + const addressList = [...this.connectedAddresses.receiving, ...this.connectedAddresses.change]; + return this.apiProvider.getAccountUtxos(addressList, this.network === 'mainnet' ? 0 : 1); + } + /** * Builds a transaction based on the provided parameters. * @param{BuildTransactionParams} arg */ - async buildTransaction(arg: BuildTransactionParams): Promise { + async buildTransaction(arg: BuildTransactionParams): Promise { const { type, params } = arg; this.ensureInitialized(); if (!params) throw new Error('Missing params'); - console.log('[Mintlayer Connect SDK] Building transaction:', type, params); + const { inputs, outputs, input_amount_coin_req, input_amount_token_req, send_token } = + this.getRequiredInputsOutputs({ type, params } as BuildTransactionParams); - const address = this.connectedAddresses; - const currentAddress = address; - const addressList = [...currentAddress.receiving, ...currentAddress.change]; + const baseFee = this.getFeeForType(type); + + return Transaction.assembleRaw( + { + outputs, + inputs, + requiredCoin: input_amount_coin_req, + requiredToken: input_amount_token_req, + sendToken: send_token, + baseFee, + deductFeeFromFirstOutput: type === 'DelegationWithdraw', + withUTXO: await this.getAssembleUtxos(arg?.opts), + forceSpendUtxo: arg?.opts?.forceSpendUtxo, + }, + { network: this.network, changeAddress: this.connectedAddresses.change[0] }, + ); + } - let data_utxos: UtxoEntry[]; + /** + * Builds an unsigned transaction from developer-forged outputs and optional + * nonce-based account inputs. + * + * Unlike {@link buildTransaction}, the transaction shape is fully chosen by the + * caller. Coin/token UTXOs are always selected automatically (hybrid model) to + * cover the outputs, protocol fees and the transaction fee, and change outputs + * are appended to the first change address. + * + * Protocol fees are computed for issuance outputs (IssueFungibleToken, + * IssueNft), DataDeposit outputs and for fee-bearing account-command inputs + * (mint/unmint/lock-supply, freeze/unfreeze, change authority/metadata). + * + * Plain strings are accepted wherever `{hex, string}` pairs are required and + * are hex-encoded automatically. + * + * @param args - The forged outputs, optional account inputs and UTXO options. + */ + async buildRawTransaction(args: RawTransactionArgs): Promise { + this.ensureInitialized(); - if (arg?.opts?.withUTXO) { - data_utxos = arg?.opts?.withUTXO; - } else { - data_utxos = await this.apiProvider.getAccountUtxos(addressList, this.network === 'mainnet' ? 0 : 1); + if (!args || typeof args !== 'object') { + throw new Error('Missing args'); + } + if (!Array.isArray(args.outputs) || args.outputs.length === 0) { + throw new Error('At least one output is required'); + } + if (args.outputs.length > MAX_RAW_OUTPUTS) { + throw new Error(`Too many outputs: ${args.outputs.length} (maximum ${MAX_RAW_OUTPUTS})`); } - const forceSpendUtxo: UtxoEntry[] = arg?.opts?.forceSpendUtxo - ? arg?.opts?.forceSpendUtxo.map((item: UtxoEntry) => ({ - outpoint: { - ...item.outpoint, - input_type: 'UTXO', - }, - utxo: item.utxo, - })) - : []; - - const utxos: UtxoEntry[] = data_utxos.filter((item: UtxoEntry) => { - if (!item.utxo) { - return false; - } + const prepared = await this.prepareRawTransaction(args); + + return Transaction.assembleRaw( + { + outputs: prepared.outputs, + inputs: prepared.inputs ?? [], + requiredCoin: prepared.requiredCoin, + requiredToken: prepared.requiredToken, + sendToken: prepared.sendToken, + baseFee: prepared.baseFee, + deductFeeFromFirstOutput: prepared.deductFeeFromFirstOutput, + withUTXO: await this.getAssembleUtxos(args.opts), + forceSpendUtxo: args.opts?.forceSpendUtxo, + }, + { network: this.network, changeAddress: this.connectedAddresses.change[0] }, + ); + } - // filter out UTXO with type htlc, they have to be added manually - if (item.utxo.type === 'Htlc') { - return false; - } + /** + * Builds a raw transaction with {@link buildRawTransaction} and signs it with + * the connected wallet. + * + * @param args - The forged outputs, optional account inputs and UTXO options. + */ + async forgeTransaction(args: RawTransactionArgs): Promise { + const tx = await this.buildRawTransaction(args); + return this.signTransaction(tx); + } - return true; - }); + /** + * Normalizes and validates developer-forged outputs/inputs and computes the + * coin/token requirements for the assembler. + * @private + */ + private async prepareRawTransaction(args: RawTransactionArgs): Promise { + const tokenDetailsCache = new Map>(); - const { inputs, outputs, input_amount_coin_req, input_amount_token_req, send_token } = - this.getRequiredInputsOutputs({ type, params } as BuildTransactionParams); + const outputs = await Promise.all( + args.outputs.map((raw, index) => this.normalizeRawOutput(raw, index, tokenDetailsCache)), + ); + const inputs = await this.normalizeRawInputs(args.inputs ?? [], tokenDetailsCache); + const { requiredCoin, requiredToken, sendToken } = await this.computeRawRequirements( + outputs, + inputs, + tokenDetailsCache, + ); - let preciseFee = BigInt(0); - let previousFee = BigInt(-1); - const MAX_ATTEMPTS = 10; - let attempts = 0; + return { outputs, inputs, requiredCoin, requiredToken, sendToken, baseFee: 0n }; + } - while (attempts < MAX_ATTEMPTS) { - attempts++; + /** + * Normalizes developer-forged account inputs, auto-filling nonces (sequentially + * per token, in input order) and token-command authorities from token details. + * @private + */ + private async normalizeRawInputs( + rawInputs: RawInput[], + tokenDetailsCache: Map>, + ): Promise { + const nonceCounters = new Map(); + const inputs: Input[] = []; - const totalFee = this.getFeeForType(type) + preciseFee; - const input_amount_coin_req_w_fee = input_amount_coin_req + totalFee; + for (const raw of rawInputs) { + if (!raw || typeof raw.input !== 'object') { + throw new Error('inputs: each input must be an object with an "input" field'); + } - const inputObjCoin = - type !== 'DelegationWithdraw' ? this.selectUTXOs(utxos, input_amount_coin_req_w_fee, null) : []; - const inputObjToken = send_token?.token_id - ? this.selectUTXOs(utxos, input_amount_token_req, send_token.token_id) - : []; + const meta = raw.input; - if (forceSpendUtxo) { - const forceCoinUtxos = forceSpendUtxo.filter((utxo) => utxo.utxo.value.type === 'Coin'); - const forceTokenUtxos = forceSpendUtxo.filter( - (utxo) => utxo.utxo.value.type === 'TokenV1' && utxo.utxo.value.token_id === send_token?.token_id, + if (meta.input_type !== 'AccountCommand' && meta.input_type !== 'Account') { + throw new Error( + `inputs: unsupported input_type "${String((meta as { input_type?: unknown }).input_type)}" — UTXO inputs are selected automatically (use opts.withUTXO to override)`, ); + } - if (forceCoinUtxos.length > 0) { - // @ts-ignore - inputObjCoin.unshift(...forceCoinUtxos); - } - if (forceTokenUtxos.length > 0) { - // @ts-ignore - inputObjToken.unshift(...forceTokenUtxos); + if (meta.input_type === 'AccountCommand') { + switch (meta.command) { + case 'MintTokens': { + const { details, authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + const amount = this.normalizeRawAmount( + meta.amount, + details.number_of_decimals, + `inputs (command ${meta.command})`, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: meta.token_id, + authority, + nonce, + amount, + }, + utxo: null, + }); + break; + } + case 'UnmintTokens': { + const { details, authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + const amount = this.normalizeRawAmount( + meta.amount, + details.number_of_decimals, + `inputs (command ${meta.command})`, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'UnmintTokens', + token_id: meta.token_id, + authority, + nonce, + amount, + }, + utxo: null, + }); + break; + } + case 'LockTokenSupply': { + const { authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'LockTokenSupply', + token_id: meta.token_id, + authority, + nonce, + }, + utxo: null, + }); + break; + } + case 'FreezeToken': { + const { authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'FreezeToken', + token_id: meta.token_id, + authority, + is_unfreezable: meta.is_unfreezable === true, + nonce, + }, + utxo: null, + }); + break; + } + case 'UnfreezeToken': { + const { authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'UnfreezeToken', + token_id: meta.token_id, + authority, + nonce, + }, + utxo: null, + }); + break; + } + case 'ChangeTokenAuthority': { + if (!meta.new_authority) { + throw new Error('inputs (ChangeTokenAuthority): new_authority is required'); + } + const { authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'ChangeTokenAuthority', + token_id: meta.token_id, + authority, + new_authority: meta.new_authority, + nonce, + }, + utxo: null, + }); + break; + } + case 'ChangeMetadataUri': { + if (!meta.new_metadata_uri) { + throw new Error('inputs (ChangeMetadataUri): new_metadata_uri is required'); + } + const { authority, nonce } = await this.resolveRawTokenCommand( + meta.command, + meta.token_id, + meta.authority, + meta.nonce, + tokenDetailsCache, + nonceCounters, + ); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'ChangeMetadataUri', + token_id: meta.token_id, + authority, + new_metadata_uri: meta.new_metadata_uri, + nonce, + }, + utxo: null, + }); + break; + } + case 'FillOrder': { + const order_id = this.validateRawId(meta.order_id, 'inputs (FillOrder)', 'order_id'); + const order: OrderData = await this.apiProvider.getOrder(order_id); + const orderNonce = this.validateNextNonce(order.nonce, `order ${order_id}`); + const nonce = this.nextRawNonce(`order:${order_id}`, orderNonce, meta.nonce, nonceCounters); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'FillOrder', + order_id, + fill_atoms: this.rawAtomsString(meta.fill_atoms, `inputs (FillOrder ${order_id})`, 'fill_atoms'), + destination: meta.destination, + nonce: String(nonce), + }, + utxo: null, + }); + break; + } + case 'ConcludeOrder': { + const order_id = this.validateRawId(meta.order_id, 'inputs (ConcludeOrder)', 'order_id'); + const order: OrderData = await this.apiProvider.getOrder(order_id); + const orderNonce = this.validateNextNonce(order.nonce, `order ${order_id}`); + const nonce = this.nextRawNonce(`order:${order_id}`, orderNonce, meta.nonce, nonceCounters); + inputs.push({ + input: { + input_type: 'AccountCommand', + command: 'ConcludeOrder', + order_id, + destination: meta.destination, + nonce, + }, + utxo: null, + }); + break; + } + default: + throw new Error(`inputs: unsupported account command "${String((meta as { command?: unknown }).command)}"`); } + } else if (meta.account_type === 'DelegationBalance') { + const delegation_id = this.validateRawId(meta.delegation_id, 'inputs (DelegationBalance)', 'delegation_id'); + const delegation: DelegationDetails = await this.apiProvider.getDelegation(delegation_id); + const delegationNonce = this.validateNextNonce(delegation.next_nonce, `delegation ${delegation_id}`); + const nonce = this.nextRawNonce(`delegation:${delegation_id}`, delegationNonce, meta.nonce, nonceCounters); + inputs.push({ + input: { + input_type: 'Account', + account_type: 'DelegationBalance', + amount: this.normalizeRawAmount(meta.amount, 11, `inputs (DelegationBalance ${delegation_id})`), + delegation_id, + nonce, + }, + }); + } else { + throw new Error( + `inputs: unsupported account_type "${String((meta as { account_type?: unknown }).account_type)}"`, + ); } + } - const totalInputValueCoin = inputObjCoin.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); - const totalInputValueToken = inputObjToken.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); + return inputs; + } - if (type !== 'DelegationWithdraw' && totalInputValueCoin < input_amount_coin_req_w_fee) { - throw new Error('Not enough coin UTXOs'); + /** + * Computes coin/token requirements for raw outputs and inputs, including + * protocol fees and the fees of the provided account inputs. + * + * Token requirements are netted against same-transaction mints: a token + * requires UTXOs only for the amount by which its outputs exceed the amount + * minted in the same transaction. Over-minting (minting more than the + * outputs spend) is allowed and simply leaves the surplus minted. + * @private + */ + private async computeRawRequirements( + outputs: Output[], + inputs: Input[], + tokenDetailsCache: Map>, + ): Promise<{ + requiredCoin: bigint; + requiredToken: bigint; + sendToken?: { token_id: string; number_of_decimals: number }; + }> { + let requiredCoin = 0n; + const tokenRequirements = new Map(); + const outputTokenTotals = new Map(); + const mintedTotals = new Map(); + + for (const input of inputs) { + if (input.input.input_type !== 'AccountCommand') { + continue; } - if (totalInputValueToken < input_amount_token_req) { - throw new Error('Not enough token UTXOs'); + if (input.input.command === 'MintTokens') { + const token_id = input.input.token_id; + mintedTotals.set(token_id, (mintedTotals.get(token_id) ?? 0n) + BigInt(input.input.amount.atoms)); } + if (input.input.command === 'UnmintTokens') { + const token_id = input.input.token_id; + tokenRequirements.set(token_id, (tokenRequirements.get(token_id) ?? 0n) + BigInt(input.input.amount.atoms)); + } + } - const changeAmountCoin = totalInputValueCoin - input_amount_coin_req_w_fee; - const changeAmountToken = totalInputValueToken - input_amount_token_req; + const addCoinRequirement = (atoms: bigint) => { + requiredCoin += atoms; + }; + const addTokenOutput = (token_id: string, atoms: bigint) => { + outputTokenTotals.set(token_id, (outputTokenTotals.get(token_id) ?? 0n) + atoms); + }; - const finalOutputs = [...outputs]; + outputs.forEach((output, index) => { + const context = `outputs[${index}]`; + switch (output.type) { + case 'Transfer': + case 'LockThenTransfer': + case 'BurnToken': { + if (output.value.type === 'Coin') { + addCoinRequirement(BigInt(output.value.amount.atoms)); + } else { + addTokenOutput(output.value.token_id, BigInt(output.value.amount.atoms)); + } + break; + } + case 'Htlc': { + addCoinRequirement(Client.HTLC_FEE_ATOMS); + if (output.value.type === 'Coin') { + addCoinRequirement(BigInt(output.value.amount.atoms)); + } else if (output.value.token_id) { + addTokenOutput(output.value.token_id, BigInt(output.value.amount.atoms)); + } + break; + } + case 'DelegateStaking': { + addCoinRequirement(BigInt(output.amount.atoms)); + break; + } + case 'CreateOrder': { + if (output.give_currency.type === 'Coin') { + addCoinRequirement(BigInt(output.give_balance.atoms)); + } else { + addTokenOutput(output.give_currency.token_id, BigInt(output.give_balance.atoms)); + } + break; + } + case 'IssueFungibleToken': { + addCoinRequirement(this.protocolFee(fungible_token_issuance_fee)); + break; + } + case 'IssueNft': { + addCoinRequirement(this.protocolFee(nft_issuance_fee)); + break; + } + case 'DataDeposit': { + addCoinRequirement(this.protocolFee(data_deposit_fee)); + break; + } + case 'CreateDelegationId': + break; + default: + throw new Error(`${context}: unknown output type`); + } + }); - if (type === 'DelegationWithdraw') { - const out = finalOutputs[0] as LockThenTransferOutput; - out.value.amount = { - atoms: (BigInt(out.value.amount.atoms) - totalFee).toString(), - decimal: (Number(BigInt(out.value.amount.atoms) - totalFee) / 1e11).toString(), - }; + // Net the token outputs against same-transaction mints; only a positive + // remainder has to be covered from token UTXOs. + for (const [token_id, outAtoms] of outputTokenTotals) { + const net = outAtoms - (mintedTotals.get(token_id) ?? 0n); + if (net > 0n) { + tokenRequirements.set(token_id, (tokenRequirements.get(token_id) ?? 0n) + net); } + } + + for (const input of inputs) { + addCoinRequirement(this.getFeeForRawInput(input)); + } + + if (tokenRequirements.size > 1) { + throw new Error( + `Raw transactions support at most one non-minted token, found: ${[...tokenRequirements.keys()].join(', ')}`, + ); + } - if (changeAmountCoin > 0n) { - finalOutputs.push({ + let requiredToken = 0n; + let sendToken: { token_id: string; number_of_decimals: number } | undefined; + + if (tokenRequirements.size === 1) { + const [token_id, atoms] = [...tokenRequirements.entries()][0]; + const details = await this.getRawTokenDetails(token_id, tokenDetailsCache); + requiredToken = atoms; + sendToken = { token_id, number_of_decimals: details.number_of_decimals }; + } + + return { requiredCoin, requiredToken, sendToken }; + } + + /** + * Returns the protocol fee (in atoms) contributed by an account input. + * @private + */ + private getFeeForRawInput(input: Input): bigint { + if (input.input.input_type !== 'AccountCommand') { + return 0n; + } + switch (input.input.command) { + case 'MintTokens': + case 'UnmintTokens': + case 'LockTokenSupply': + case 'FreezeToken': + case 'UnfreezeToken': + case 'ChangeTokenAuthority': + case 'ChangeMetadataUri': + return this.getFeeForCommand(input.input.command); + default: + return 0n; // FillOrder, ConcludeOrder and DelegationBalance carry no protocol fee + } + } + + /** + * Normalizes and validates a developer-forged output. + * @private + */ + private async normalizeRawOutput( + raw: RawOutput, + index: number, + tokenDetailsCache: Map>, + ): Promise { + const context = `outputs[${index}]`; + + if (!raw || typeof raw !== 'object' || !raw.type) { + throw new Error(`${context}: output must be an object with a known "type"`); + } + + switch (raw.type) { + case 'Transfer': { + if (!raw.destination) { + throw new Error(`${context}: destination is required`); + } + return { type: 'Transfer', - value: { - type: 'Coin', - amount: { - atoms: changeAmountCoin.toString(), - decimal: atomsToDecimal(changeAmountCoin.toString(), 11).toString(), - }, + destination: raw.destination, + value: await this.normalizeRawValue(raw.value, context, tokenDetailsCache), + }; + } + case 'LockThenTransfer': { + if (!raw.destination) { + throw new Error(`${context}: destination is required`); + } + return { + type: 'LockThenTransfer', + destination: raw.destination, + value: await this.normalizeRawValue(raw.value, context, tokenDetailsCache), + lock: this.normalizeRawLock(raw.lock, context), + }; + } + case 'BurnToken': { + return { type: 'BurnToken', value: await this.normalizeRawValue(raw.value, context, tokenDetailsCache) }; + } + case 'DataDeposit': { + const data = this.sanitizeRawDisplayString(raw.data, context, 'data', MAX_RAW_DATA_DEPOSIT_LENGTH); + if (data.length === 0) { + throw new Error(`${context}: data must be a non-empty string`); + } + return { type: 'DataDeposit', data }; + } + case 'IssueFungibleToken': { + if (!raw.authority) { + throw new Error(`${context}: authority is required`); + } + this.validateTokenDecimals(raw.number_of_decimals, context); + let total_supply: TotalSupplyValue; + if (raw.total_supply?.type === 'Unlimited' || raw.total_supply?.type === 'Lockable') { + total_supply = { type: raw.total_supply.type }; + } else if (raw.total_supply?.type === 'Fixed') { + total_supply = { + type: 'Fixed', + amount: this.normalizeRawAmount(raw.total_supply.amount, raw.number_of_decimals, context), + }; + } else { + throw new Error(`${context}: total_supply.type must be "Unlimited", "Lockable" or "Fixed"`); + } + return { + type: 'IssueFungibleToken', + authority: raw.authority, + is_freezable: raw.is_freezable === true, + metadata_uri: this.normalizeRawStringField(raw.metadata_uri, context, 'metadata_uri', MAX_RAW_URI_LENGTH), + number_of_decimals: raw.number_of_decimals, + token_ticker: this.normalizeRawStringField(raw.token_ticker, context, 'token_ticker', MAX_RAW_TICKER_LENGTH), + total_supply, + }; + } + case 'IssueNft': { + if (!raw.destination) { + throw new Error(`${context}: destination is required`); + } + const data = raw.data; + if (!data || typeof data !== 'object') { + throw new Error(`${context}: data is required`); + } + return { + type: 'IssueNft', + destination: raw.destination, + token_id: raw.token_id ? this.validateRawId(raw.token_id, context, 'token_id') : '', + data: { + name: this.normalizeRawStringField(data.name, context, 'name', MAX_RAW_NFT_NAME_LENGTH), + ticker: this.normalizeRawStringField(data.ticker, context, 'ticker', MAX_RAW_TICKER_LENGTH), + description: this.normalizeRawStringField( + data.description, + context, + 'description', + MAX_RAW_NFT_DESCRIPTION_LENGTH, + ), + media_hash: this.normalizeRawStringField(data.media_hash, context, 'media_hash', MAX_RAW_HASH_LENGTH), + media_uri: this.normalizeRawStringField(data.media_uri, context, 'media_uri', MAX_RAW_URI_LENGTH), + icon_uri: this.normalizeRawStringField(data.icon_uri, context, 'icon_uri', MAX_RAW_URI_LENGTH), + additional_metadata_uri: this.normalizeRawStringField( + data.additional_metadata_uri, + context, + 'additional_metadata_uri', + MAX_RAW_URI_LENGTH, + ), + creator: raw.creator + ? this.sanitizeRawDisplayString(raw.creator, context, 'creator', MAX_RAW_CREATOR_LENGTH) + : null, }, - destination: currentAddress.change[0], - }); + }; } - - if (changeAmountToken > 0n && send_token) { - const decimals = send_token.number_of_decimals; - finalOutputs.push({ - type: 'Transfer', - value: { - type: 'TokenV1', - token_id: send_token.token_id, - amount: { - atoms: changeAmountToken.toString(), - decimal: atomsToDecimal(changeAmountToken.toString(), decimals).toString(), - }, + case 'CreateOrder': { + if (!raw.conclude_destination) { + throw new Error(`${context}: conclude_destination is required`); + } + const ask_currency = this.validateRawCurrency(raw.ask_currency, context, 'ask_currency'); + const give_currency = this.validateRawCurrency(raw.give_currency, context, 'give_currency'); + const askDecimals = + ask_currency.type === 'Coin' + ? 11 + : (await this.getRawTokenDetails(ask_currency.token_id, tokenDetailsCache)).number_of_decimals; + const giveDecimals = + give_currency.type === 'Coin' + ? 11 + : (await this.getRawTokenDetails(give_currency.token_id, tokenDetailsCache)).number_of_decimals; + return { + type: 'CreateOrder', + conclude_destination: raw.conclude_destination, + ask_currency, + ask_balance: this.normalizeRawAmount(raw.ask_balance, askDecimals, context), + give_currency, + give_balance: this.normalizeRawAmount(raw.give_balance, giveDecimals, context), + initially_asked: this.normalizeRawAmount(raw.initially_asked, askDecimals, context), + initially_given: this.normalizeRawAmount(raw.initially_given, giveDecimals, context), + }; + } + case 'CreateDelegationId': { + if (!raw.destination) { + throw new Error(`${context}: destination is required`); + } + const pool_id = this.validateRawId(raw.pool_id, context, 'pool_id'); + return { type: 'CreateDelegationId', destination: raw.destination, pool_id }; + } + case 'DelegateStaking': { + const delegation_id = this.validateRawId(raw.delegation_id, context, 'delegation_id'); + return { + type: 'DelegateStaking', + delegation_id, + amount: this.normalizeRawAmount(raw.amount, 11, context), + }; + } + case 'Htlc': { + if (!raw.htlc || typeof raw.htlc !== 'object') { + throw new Error(`${context}: htlc is required`); + } + if (!raw.htlc.spend_key || !raw.htlc.refund_key) { + throw new Error(`${context}: htlc.spend_key and htlc.refund_key are required`); + } + return { + type: 'Htlc', + value: await this.normalizeRawValue(raw.value, context, tokenDetailsCache), + htlc: { + refund_key: raw.htlc.refund_key, + spend_key: raw.htlc.spend_key, + secret_hash: this.normalizeRawSecretHash(raw.htlc.secret_hash, context), + refund_timelock: this.normalizeRawTimelock(raw.htlc.refund_timelock, context), }, - destination: currentAddress.change[0], - }); + }; } + default: + throw new Error(`${context}: unknown output type "${String((raw as { type?: unknown }).type)}"`); + } + } - const finalInputs = [...inputs, ...inputObjCoin, ...inputObjToken]; + /** + * Validates a Coin/TokenV1 currency discriminant, shared by value and + * CreateOrder currency normalization. Returns fresh canonical objects so + * extra caller properties cannot leak into the transaction JSON. + * @private + */ + private validateRawCurrency( + currency: { type: 'Coin' } | { type: 'TokenV1'; token_id: string }, + context: string, + name: string, + ): { type: 'Coin' } | { type: 'TokenV1'; token_id: string } { + if (!currency || (currency.type !== 'Coin' && currency.type !== 'TokenV1')) { + throw new Error(`${context}: ${name}.type must be "Coin" or "TokenV1"`); + } + if (currency.type === 'TokenV1' && !currency.token_id) { + throw new Error(`${context}: ${name} TokenV1 requires token_id`); + } + return currency.type === 'Coin' ? { type: 'Coin' } : { type: 'TokenV1', token_id: currency.token_id }; + } - const JSONRepresentation: TransactionJSONRepresentation = { - inputs: finalInputs, - outputs: finalOutputs, - fee: { - atoms: totalFee.toString(), - decimal: atomsToDecimal(totalFee.toString(), 11).toString(), - }, - id: 'to_be_filled_in', - }; + /** + * Normalizes a developer-forged value (Coin or TokenV1). The decimal amount + * is always recomputed from the validated atoms (11 decimals for Coin, the + * token's number_of_decimals for TokenV1) so wallets can trust the display + * value they show the user. + * @private + */ + private async normalizeRawValue( + value: RawValue, + context: string, + tokenDetailsCache: Map>, + ): Promise { + if (!value || typeof value !== 'object') { + throw new Error(`${context}: value is required`); + } + const currency = this.validateRawCurrency(value, context, 'value'); + if (currency.type === 'Coin') { + return { type: 'Coin', amount: this.normalizeRawAmount(value.amount, 11, context) }; + } + const details = await this.getRawTokenDetails(currency.token_id, tokenDetailsCache); + return { + type: 'TokenV1', + token_id: currency.token_id, + amount: this.normalizeRawAmount(value.amount, details.number_of_decimals, context), + }; + } - const BINRepresentation = this.getTransactionBINrepresentation( - JSONRepresentation, - this.network === 'mainnet' ? 0 : 1, + /** + * Validates an amount and normalizes it to the canonical `{atoms, decimal}` + * fields. The decimal value is always recomputed from the validated atoms — + * a caller-supplied decimal that disagrees is rejected, never echoed. + * @private + */ + private normalizeRawAmount(amount: RawAmount, decimals: number, context: string): AmountFields { + if (!amount || typeof amount !== 'object') { + throw new Error(`${context}: amount is required`); + } + const atoms = this.rawAtomsString(amount.atoms, context); + const decimal = atomsToDecimal(atoms, decimals); + if (amount.decimal !== undefined && amount.decimal !== null && String(amount.decimal) !== decimal) { + throw new Error( + `${context}: amount.decimal "${amount.decimal}" does not match ${atoms} atoms at ${decimals} decimals ("${decimal}") — decimals are always recomputed from atoms`, ); + } + return { atoms, decimal }; + } - 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); - - if (nextPreciseFee === preciseFee || nextPreciseFee === previousFee) { - const transaction = encode_transaction( - mergeUint8Arrays(BINRepresentation.inputs), - mergeUint8Arrays(BINRepresentation.outputs), - BigInt(0), - ); - - const transaction_id = get_transaction_id(transaction, true); - - if (finalOutputs.some((output) => output.type === 'IssueNft')) { - const block_height = 200000n; // TODO: Get the current block height - const token_id = get_token_id( - mergeUint8Arrays(BINRepresentation.inputs), - block_height, - 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, - }; - } + /** + * Validates that an atoms value is a non-negative integer and returns it as string. + * @private + */ + private rawAtomsString(atoms: string | number, context: string, label = 'amount.atoms'): string { + const atomsStr = String(atoms); + if (!/^\d+$/.test(atomsStr)) { + throw new Error(`${context}: ${label} must be a non-negative integer expressed in atoms (got "${atoms}")`); + } + return atomsStr; + } - const HEXRepresentation_unsigned = transaction.reduce( - (acc, byte) => acc + byte.toString(16).padStart(2, '0'), - '', - ); + /** + * Validates a transaction id (64-character hex, case-insensitive) before it + * is interpolated into API paths or parsed into bytes. + * @private + */ + private validateTransactionId(transaction_id: string, context: string): string { + if (typeof transaction_id !== 'string' || !/^[0-9a-fA-F]{64}$/.test(transaction_id)) { + throw new Error(`${context}: transaction_id must be a 64-character hex string`); + } + return transaction_id; + } - return { - JSONRepresentation: { - ...JSONRepresentation, - id: transaction_id, - }, - BINRepresentation, - HEXRepresentation_unsigned, - transaction_id, - }; - } - previousFee = preciseFee; - preciseFee = nextPreciseFee; + /** + * Validates a token number_of_decimals value (integer 0–18). Shared by raw + * issuance outputs and token-details lookup so both paths enforce the same + * bound. + * @private + */ + private validateTokenDecimals(value: unknown, context: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 18) { + throw new Error(`${context}: number_of_decimals must be an integer between 0 and 18 (got ${String(value)})`); } + return value; + } - throw new Error('Failed to build transaction after maximum attempts'); + /** + * Validates an on-chain identifier (token/order/delegation/pool id) before it + * is interpolated into API paths or passed to the wasm encoders. Mintlayer + * ids are bech32-like (e.g. 'tmltk1…', 'tdelg1…'), hence lowercase + * alphanumeric with sane length bounds. + * @private + */ + private validateRawId(id: string, context: string, name: string): string { + if (typeof id !== 'string' || id.length < 10 || id.length > 100 || !/^[a-z0-9]+$/.test(id)) { + throw new Error(`${context}: ${name} has an invalid format`); + } + return id; } /** - * Returns the transaction binary representation. - * @param transactionJSONrepresentation - * @param _network + * Strips C0/C1 control characters and Unicode bidi marks from a display + * string and enforces a maximum length. + * @private */ - getTransactionBINrepresentation( - transactionJSONrepresentation: TransactionJSONRepresentation, - _network: Network, - ): { - inputs: Uint8Array[]; - outputs: Uint8Array[]; - transactionsize: number; - } { - const network = _network; - // Binarisation - // calculate fee and prepare as much transaction as possible - const inputs = transactionJSONrepresentation.inputs; - const outpointedSourceIds = (inputs as UtxoInput[]) - .filter(({ input }) => input.input_type === 'UTXO') - .map(({ input }) => { - const bytes = Uint8Array.from(input.source_id.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); - return { - source_id: encode_outpoint_source_id(bytes, SourceId.Transaction), - index: input.index, - }; - }); - const inputsIds = outpointedSourceIds.map((source) => encode_input_for_utxo(source.source_id, source.index)); + private sanitizeRawDisplayString(value: string, context: string, name: string, maxLength: number): string { + if (typeof value !== 'string') { + throw new Error(`${context}: ${name} must be a string`); + } + const sanitized = value.replace(DISPLAY_STRING_SANITIZE_RE, ''); + if (sanitized.length > maxLength) { + throw new Error(`${context}: ${name} must be at most ${maxLength} characters`); + } + return sanitized; + } - const inputCommands = (transactionJSONrepresentation.inputs as any[]) - .filter(({ input }) => input.input_type === 'AccountCommand' || input.input_type === 'Account') - .map(({ input }) => { - if (input.command === 'ConcludeOrder') { - const block_height = 200000n; // TODO: Get the current block height - return encode_input_for_conclude_order(input.order_id, BigInt(input.nonce.toString()), block_height, network); - } - if (input.command === 'FillOrder') { - const block_height = 200000n; // TODO: Get the current block height - return encode_input_for_fill_order( - input.order_id, - Amount.from_atoms(input.fill_atoms.toString()), - input.destination, - BigInt(input.nonce.toString()), - BigInt(block_height), - network, - ); - } - if (input.command === 'MintTokens') { - return encode_input_for_mint_tokens( - input.token_id, - Amount.from_atoms(input.amount.atoms.toString()), - input.nonce.toString(), - network, - ); - } - if (input.command === 'UnmintTokens') { - return encode_input_for_unmint_tokens(input.token_id, input.nonce.toString(), network); - } - if (input.command === 'LockTokenSupply') { - return encode_input_for_lock_token_supply(input.token_id, input.nonce.toString(), network); - } - if (input.command === 'ChangeTokenAuthority') { - return encode_input_for_change_token_authority( - input.token_id, - input.new_authority, - input.nonce.toString(), - network, - ); - } - if (input.command === 'ChangeMetadataUri') { - return encode_input_for_change_token_metadata_uri( - input.token_id, - input.new_metadata_uri, - input.nonce.toString(), - network, - ); - } - if (input.command === 'FreezeToken') { - return encode_input_for_freeze_token( - input.token_id, - input.is_unfreezable ? TokenUnfreezable.Yes : TokenUnfreezable.No, - input.nonce.toString(), - network, - ); - } - if (input.command === 'UnfreezeToken') { - return encode_input_for_unfreeze_token(input.token_id, input.nonce.toString(), network); - } - if (input.account_type === 'DelegationBalance') { - return encode_input_for_withdraw_from_delegation( - input.delegation_id, - Amount.from_atoms(input.amount.atoms.toString()), - BigInt(input.nonce.toString()), - network, - ); - } - }); + /** + * Wraps a plain string into the canonical `{hex, string}` pair, or validates + * an already-paired value. The hex is always recomputed from the sanitized + * string; a caller-provided hex that disagrees is rejected. + * @private + */ + private normalizeRawStringField( + field: RawStringField, + context: string, + name: string, + maxLength: number, + ): { hex: string; string: string } { + let string_: string; + if (typeof field === 'string') { + string_ = this.sanitizeRawDisplayString(field, context, name, maxLength); + } else if (field && typeof field === 'object' && typeof field.string === 'string') { + string_ = this.sanitizeRawDisplayString(field.string, context, name, maxLength); + } else { + throw new Error(`${context}: ${name} must be a string or a {hex, string} pair`); + } - const inputsArray = [...inputCommands, ...inputsIds].filter((x): x is NonNullable => x !== undefined); + const hex = this.stringToHex(string_); - const outputsArrayItems = transactionJSONrepresentation.outputs.map((output) => { - if (output.type === 'Transfer') { - if (output.value.type === 'TokenV1') { - return encode_output_token_transfer( - Amount.from_atoms(output.value.amount.atoms), - output.destination, - output.value.token_id, - network, - ); - } else { - return encode_output_transfer(Amount.from_atoms(output.value.amount.atoms), output.destination, network); - } - } - if (output.type === 'LockThenTransfer') { - let lockEncoded: Uint8Array = new Uint8Array(); - if (output.lock.type === 'UntilTime') { - // @ts-ignore - lockEncoded = encode_lock_until_time(BigInt(output.lock.content.timestamp)); // TODO: check if timestamp is correct - } - if (output.lock.type === 'ForBlockCount') { - lockEncoded = encode_lock_for_block_count(BigInt(output.lock.content)); - } - if (output.value.type === 'TokenV1') { - return encode_output_token_lock_then_transfer( - Amount.from_atoms(output.value.amount.atoms), - output.destination, - output.value.token_id, - lockEncoded, - network, - ); - } else { - return encode_output_lock_then_transfer( - Amount.from_atoms(output.value.amount.atoms), - output.destination, - lockEncoded, - network, - ); - } - } - if (output.type === 'CreateOrder') { - return encode_create_order_output( - Amount.from_atoms(output.ask_balance.atoms.toString()), //ask_amount - output.ask_currency.type === 'TokenV1' ? output.ask_currency.token_id : null, // ask_token_id - Amount.from_atoms(output.give_balance.atoms.toString()), //give_amount - output.give_currency.type === 'TokenV1' ? output.give_currency.token_id : null, //give_token_id - output.conclude_destination, // conclude_address - network, // network - ); + if (field && typeof field === 'object' && field.hex !== undefined) { + if (typeof field.hex !== 'string' || field.hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(field.hex)) { + throw new Error(`${context}: ${name}.hex must be an even-length hex string`); } - if (output.type === 'BurnToken') { - if (output.value.type === 'TokenV1') { - return encode_output_token_burn( - Amount.from_atoms(output.value.amount.atoms.toString()), // amount - output.value.token_id, // token_id - network, // network - ); - } - if (output.value.type === 'Coin') { - return encode_output_coin_burn( - Amount.from_atoms(output.value.amount.atoms.toString()), // amount - ); - } - } - if (output.type === 'IssueNft') { - const { name, ticker, description, media_hash, creator, media_uri, icon_uri, additional_metadata_uri } = - output.data; - - const { destination: address, token_id } = output; - - const chainTip = '200000'; // TODO unhardcode - - return encode_output_issue_nft( - token_id as string, - address, - name.string, - ticker.string, - description.string, - stringToUint8Array(media_hash.string), - null, // TODO: check for public key, key hash is not working - media_uri.string, - icon_uri.string, - additional_metadata_uri.string, - BigInt(chainTip), - network, - ); - } - if (output.type === 'IssueFungibleToken') { - const { authority, is_freezable, metadata_uri, number_of_decimals, token_ticker, total_supply } = output; - - const chainTip = '200000'; // TODO: unhardcode height - - const is_token_freezable = is_freezable ? FreezableToken.Yes : FreezableToken.No; - - const supply_amount = - total_supply.type === 'Fixed' ? Amount.from_atoms(total_supply.amount.atoms.toString()) : null; - - const total_supply_type = - total_supply.type === 'Fixed' - ? TotalSupply.Fixed - : total_supply.type === 'Lockable' - ? TotalSupply.Lockable - : TotalSupply.Unlimited; - - // const encoder = new TextEncoder() - - return encode_output_issue_fungible_token( - authority, // ok - token_ticker.string, // ok - metadata_uri.string, // ok - number_of_decimals, // ok - total_supply_type, // ok - supply_amount, // ok - is_token_freezable, // ok - BigInt(chainTip), // ok - network, - ); + if (field.hex.toLowerCase() !== hex) { + throw new Error(`${context}: ${name}.hex does not match ${name}.string`); } + } - if (output.type === 'DataDeposit') { - return encode_output_data_deposit(new TextEncoder().encode(output.data)); - } + return { hex, string: string_ }; + } - if (output.type === 'CreateDelegationId') { - return encode_output_create_delegation(output.pool_id, output.destination, network); - } + /** + * Normalizes an HTLC secret hash (plain hex string or `{hex, string}` pair). + * @private + */ + private normalizeRawSecretHash( + hash: string | { hex: string; string: string | null }, + context: string, + ): { hex: string; string: string | null } { + if (typeof hash === 'string') { + return { hex: this.rawHexString(hash, context, 'htlc.secret_hash'), string: null }; + } + if (hash && typeof hash.hex === 'string') { + return { hex: this.rawHexString(hash.hex, context, 'htlc.secret_hash'), string: hash.string ?? null }; + } + throw new Error(`${context}: htlc.secret_hash must be a hex string or a {hex, string} pair`); + } - if (output.type === 'DelegateStaking') { - return encode_output_delegate_staking(Amount.from_atoms(output.amount.atoms), output.delegation_id, network); - } + /** + * Validates a hex string. + * @private + */ + private rawHexString(value: string, context: string, name: string): string { + if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) { + throw new Error(`${context}: ${name} must be an even-length hex string`); + } + return value; + } - if (output.type === 'Htlc') { - let refund_timelock: Uint8Array = new Uint8Array(); + /** + * Normalizes a LockThenTransfer lock. Contents are validated as non-negative + * integers and kept as strings (avoids Number precision loss; BigInt is + * applied at encoding time). + * @private + */ + private normalizeRawLock( + lock: + | { type: 'ForBlockCount'; content: string | number } + | { type: 'UntilTime'; content: string | number | { timestamp: string | number } }, + context: string, + ): { type: 'ForBlockCount' | 'UntilTime'; content: string | { timestamp: string } } { + return this.normalizeRawTimelockContent(lock, context, 'lock.content', 'lock.type'); + } - if (output.htlc.refund_timelock.type === 'UntilTime') { - refund_timelock = encode_lock_until_time(BigInt(output.htlc.refund_timelock.content.timestamp)); // TODO: check if timestamp is correct - } - if (output.htlc.refund_timelock.type === 'ForBlockCount') { - refund_timelock = encode_lock_for_block_count(BigInt(output.htlc.refund_timelock.content)); - } + /** + * Normalizes an HTLC refund timelock. Contents are validated as non-negative + * integers and kept as strings (BigInt is applied at encoding time). + * @private + */ + private normalizeRawTimelock( + timelock: + | { type: 'UntilTime'; content: { timestamp: string | number } } + | { type: 'ForBlockCount'; content: string | number }, + context: string, + ): Timelock { + return this.normalizeRawTimelockContent(timelock, context, 'content', 'htlc.refund_timelock.type'); + } - return encode_output_htlc( - Amount.from_atoms(output.value.amount.atoms), - output.value.token_id, - output.htlc.secret_hash.hex, - output.htlc.spend_key, - output.htlc.refund_key, - refund_timelock, - network, - ); - } - }); - const outputsArray = outputsArrayItems.filter((x): x is NonNullable => x !== undefined); + /** + * Shared implementation for LockThenTransfer locks and HTLC refund timelocks: + * validates the discriminant and the numeric content (as ^\d+$ strings) and + * returns the canonical shape used by the encoders. + * @private + */ + private normalizeRawTimelockContent( + lock: + | { type: 'ForBlockCount'; content: string | number } + | { type: 'UntilTime'; content: string | number | { timestamp: string | number } }, + context: string, + contentLabel: string, + typeLabel: string, + ): { type: 'ForBlockCount'; content: string } | { type: 'UntilTime'; content: { timestamp: string } } { + if (!lock || (lock.type !== 'ForBlockCount' && lock.type !== 'UntilTime')) { + throw new Error(`${context}: ${typeLabel} must be "ForBlockCount" or "UntilTime"`); + } + if (lock.type === 'ForBlockCount') { + return { type: 'ForBlockCount', content: this.rawAtomsString(lock.content, context, contentLabel) }; + } + const timestamp = typeof lock.content === 'object' && lock.content !== null ? lock.content.timestamp : lock.content; + return { type: 'UntilTime', content: { timestamp: this.rawAtomsString(timestamp, context, 'timestamp') } }; + } - const inputAddresses: string[] = (transactionJSONrepresentation.inputs as UtxoInput[]) - // @ts-ignore - .filter(({ input, utxo }) => input.input_type === 'UTXO' || utxo?.htlc) - .map((input) => { - if (input.utxo.destination){ - return input.utxo.destination; - } - // @ts-ignore - if (input?.utxo?.htlc) { - // @ts-ignore - return [input.utxo.htlc.spend_key, input.utxo.htlc.refund_key]; // TODO: need to handle spend too - } - }).flat(); + /** + * Fetches (and caches) token details for decimals/nonce/authority inference. + * The cache stores the promise so concurrent lookups share one request. + * @private + */ + private async getRawTokenDetails(token_id: string, cache: Map>): Promise { + this.validateRawId(token_id, 'token_id lookup', 'token_id'); + let details = cache.get(token_id); + if (!details) { + details = this.fetchRawTokenDetails(token_id); + cache.set(token_id, details); + } + return details; + } - const firstInput = transactionJSONrepresentation.inputs[0]?.input as any; + /** + * Fetches token details and validates their shape before they feed decimal + * recomputation, nonce assignment and authority inference. + * @private + */ + private async fetchRawTokenDetails(token_id: string): Promise { + const raw = await this.apiProvider.getToken(token_id); - if (firstInput?.account_type === 'DelegationBalance') { - inputAddresses.push((transactionJSONrepresentation.outputs[0] as any).destination); + if (!raw || typeof raw !== 'object') { + throw new Error(`Token ${token_id} not found or returned unexpected data`); } - if (firstInput?.input_type === 'AccountCommand') { - if (firstInput.destination) { - inputAddresses.push(firstInput.destination); - } + const { number_of_decimals, authority, next_nonce } = raw as Partial; - if (firstInput.authority) { - inputAddresses.push(firstInput.authority); - } + this.validateTokenDecimals(number_of_decimals, `Token ${token_id}`); + + if (typeof authority !== 'string' || authority.length === 0) { + throw new Error(`Token ${token_id} returned an invalid authority`); } - if (firstInput?.input_type === 'Account') { - if (firstInput.destination) { - inputAddresses.push(firstInput.destination); - } + // A string next_nonce ('7') would corrupt nonce arithmetic via concatenation + if ( + next_nonce !== undefined && + (typeof next_nonce !== 'number' || !Number.isInteger(next_nonce) || next_nonce < 0) + ) { + throw new Error(`Token ${token_id} returned an invalid next_nonce: ${String(next_nonce)}`); + } - if (firstInput.authority) { - inputAddresses.push(firstInput.authority); - } + return raw as TokenDetails; + } + + /** + * Resolves the shared preamble of a token-command input: token details, + * the authority (inferred from token details when omitted) and the next + * nonce for the token. + * @private + */ + private async resolveRawTokenCommand( + command: string, + token_id: string, + authorityOverride: string | undefined, + explicitNonce: number | undefined, + tokenDetailsCache: Map>, + nonceCounters: Map, + ): Promise<{ details: TokenDetails; authority: string; nonce: number }> { + const details = await this.getRawTokenDetails(token_id, tokenDetailsCache); + const authority = this.getRawAuthority(authorityOverride, details, command); + const nonce = this.nextRawNonce(token_id, details.next_nonce ?? 0, explicitNonce, nonceCounters); + return { details, authority, nonce }; + } + + /** + * Resolves the authority for a token-command input. + * @private + */ + private getRawAuthority(authority: string | undefined, details: TokenDetails, command: string): string { + const resolved = authority || details.authority; + if (!resolved) { + throw new Error(`inputs (${command}): authority is required and could not be inferred from token details`); } + return resolved; + } - const transactionsize = estimate_transaction_size( - mergeUint8Arrays(inputsArray), - inputAddresses, - mergeUint8Arrays(outputsArray), - network, - ); + /** + * Runtime-validates a nonce served by the API before it feeds nonce + * arithmetic — a string ('7') would corrupt `base + offset` via + * concatenation (same contract as the token next_nonce validation). + * @private + */ + private validateNextNonce(nonce: unknown, context: string): number { + if (typeof nonce !== 'number' || !Number.isInteger(nonce) || nonce < 0) { + throw new Error(`${context} returned an invalid next_nonce: ${String(nonce)}`); + } + return nonce; + } - return { - inputs: inputsArray, - outputs: outputsArray, - transactionsize, - }; + /** + * Assigns the next nonce for a logical sequence (a token, order or + * delegation, identified by `key`): explicit nonces win and must not fall + * below the already-assigned ones; auto nonces continue sequentially from + * `base` in input order. Explicit nonces advance the counter so later + * inputs cannot collide with them. + * @private + */ + private nextRawNonce( + key: string, + base: number, + explicitNonce: number | undefined, + counters: Map, + ): number { + const offset = counters.get(key) ?? 0; + if (explicitNonce !== undefined) { + if (!Number.isInteger(explicitNonce) || explicitNonce < 0) { + throw new Error(`inputs: nonce must be a non-negative integer (${key})`); + } + if (explicitNonce < base + offset) { + throw new Error( + `inputs: explicit nonce ${explicitNonce} for ${key} is below the next expected nonce ${base + offset}`, + ); + } + counters.set(key, explicitNonce - base + 1); + return explicitNonce; + } + counters.set(key, offset + 1); + return base + offset; } /** @@ -2960,9 +3330,10 @@ class Client { * @param token_id - Optional token ID (if transferring tokens instead of base coin) * @returns A transaction ready to be signed */ - async buildTransfer({ to, amount, token_id }: TransferArgs): Promise { + async buildTransfer({ to, amount, token_id }: TransferArgs): Promise { this.ensureInitialized(); if (token_id) { + this.validateRawId(token_id, 'transfer', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details: TokenDetails = token; return this.buildTransaction({ type: 'Transfer', params: { to, amount, token_id, token_details } }); @@ -2991,7 +3362,7 @@ class Client { * @param token_id - The NFT token ID to transfer * @returns A transaction ready to be signed */ - async buildTransferNft({ to, token_id }: TransferNftArgs): Promise { + async buildTransferNft({ to, token_id }: TransferNftArgs): Promise { this.ensureInitialized(); if (!token_id) { @@ -2999,6 +3370,7 @@ class Client { } const amount = 1; + this.validateRawId(token_id, 'nft lookup', 'token_id'); const token = await this.apiProvider.getNft(token_id); const token_details: TokenDetails = token; token_details.number_of_decimals = 0; // that's NFT @@ -3020,7 +3392,13 @@ class Client { /** * Builds a delegation creation transaction without signing it. */ - async buildDelegate({ pool_id, destination }: { pool_id: string; destination: string }): Promise { + async buildDelegate({ + pool_id, + destination, + }: { + pool_id: string; + destination: string; + }): Promise { this.ensureInitialized(); return this.buildTransaction({ type: 'CreateDelegationId', params: { pool_id, destination } }); } @@ -3039,7 +3417,7 @@ class Client { /** * Builds an NFT issuance transaction without signing it. */ - async buildIssueNft(tokenData: IssueNftArgs): Promise { + async buildIssueNft(tokenData: IssueNftArgs): Promise { this.ensureInitialized(); const description = tokenData.description; @@ -3078,7 +3456,7 @@ class Client { token_ticker, supply_type, supply_amount, - }: IssueTokenArgs): Promise { + }: IssueTokenArgs): Promise { this.ensureInitialized(); return this.buildTransaction({ type: 'IssueFungibleToken', @@ -3121,8 +3499,9 @@ class Client { /** * Builds a token minting transaction without signing it. */ - async buildMintToken({ destination, amount, token_id }: MintTokenArgs): Promise { + async buildMintToken({ destination, amount, token_id }: MintTokenArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3147,8 +3526,9 @@ class Client { /** * Builds a token unminting transaction without signing it. */ - async buildUnmintToken({ amount, token_id }: UnmintTokenArgs): Promise { + async buildUnmintToken({ amount, token_id }: UnmintTokenArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3169,8 +3549,9 @@ class Client { /** * Builds a token supply locking transaction without signing it. */ - async buildLockTokenSupply({ token_id }: LockTokenSupplyArgs): Promise { + async buildLockTokenSupply({ token_id }: LockTokenSupplyArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3190,8 +3571,12 @@ class Client { /** * Builds a token authority change transaction without signing it. */ - async buildChangeTokenAuthority({ token_id, new_authority }: ChangeTokenAuthorityArgs): Promise { + async buildChangeTokenAuthority({ + token_id, + new_authority, + }: ChangeTokenAuthorityArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3215,8 +3600,9 @@ class Client { /** * Builds a token metadata URI change transaction without signing it. */ - async buildChangeMetadataUri({ token_id, new_metadata_uri }: ChangeMetadataUriArgs): Promise { + async buildChangeMetadataUri({ token_id, new_metadata_uri }: ChangeMetadataUriArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3240,8 +3626,9 @@ class Client { /** * Builds a token freezing transaction without signing it. */ - async buildFreezeToken({ token_id, is_unfreezable }: FreezeTokenArgs): Promise { + async buildFreezeToken({ token_id, is_unfreezable }: FreezeTokenArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3265,8 +3652,9 @@ class Client { /** * Builds a token unfreezing transaction without signing it. */ - async buildUnfreezeToken({ token_id }: UnfreezeTokenArgs): Promise { + async buildUnfreezeToken({ token_id }: UnfreezeTokenArgs): Promise { this.ensureInitialized(); + this.validateRawId(token_id, 'token lookup', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details = token; @@ -3292,17 +3680,19 @@ class Client { ask_amount, give_token, give_amount, - }: CreateOrderArgs): Promise { + }: CreateOrderArgs): Promise { this.ensureInitialized(); let ask_token_details = null; let give_token_details = null; if (ask_token !== 'Coin') { + this.validateRawId(ask_token, 'create order', 'ask_token'); ask_token_details = await this.apiProvider.getToken(ask_token); } if (give_token !== 'Coin') { + this.validateRawId(give_token, 'create order', 'give_token'); give_token_details = await this.apiProvider.getToken(give_token); } @@ -3349,8 +3739,9 @@ class Client { /** * Builds an order fill transaction without signing it. */ - async buildFillOrder({ order_id, amount, destination }: FillOrderArgs): Promise { + async buildFillOrder({ order_id, amount, destination }: FillOrderArgs): Promise { this.ensureInitialized(); + this.validateRawId(order_id, 'fill order', 'order_id'); const data = await this.apiProvider.getOrder(order_id); const order_details: OrderData = data; @@ -3404,8 +3795,9 @@ class Client { /** * Builds an order conclusion transaction without signing it. */ - async buildConcludeOrder({ order_id }: ConcludeOrderArgs): Promise { + async buildConcludeOrder({ order_id }: ConcludeOrderArgs): Promise { this.ensureInitialized(); + this.validateRawId(order_id, 'conclude order', 'order_id'); const order: OrderData = await this.apiProvider.getOrder(order_id); return this.buildTransaction({ type: 'ConcludeOrder', params: { order } }); @@ -3424,13 +3816,19 @@ class Client { /** * Builds a bridge request transaction without signing it. */ - async buildBridgeRequest({ destination, amount, token_id, intent }: BridgeRequestArgs): Promise { + async buildBridgeRequest({ + destination, + amount, + token_id, + intent, + }: BridgeRequestArgs): Promise { this.ensureInitialized(); if (!token_id) { throw new Error('Token is mandatory'); } + this.validateRawId(token_id, 'bridge request', 'token_id'); const token_details = await this.apiProvider.getToken(token_id); const tx = await this.buildTransaction({ @@ -3456,11 +3854,12 @@ class Client { /** * Builds a burn transaction without signing it. */ - async buildBurn({ token_id, amount }: BurnArgs): Promise { + async buildBurn({ token_id, amount }: BurnArgs): Promise { this.ensureInitialized(); let token_details: TokenDetails | undefined = undefined; if (token_id !== 'Coin' && token_id !== null) { + this.validateRawId(token_id, 'burn', 'token_id'); token_details = await this.apiProvider.getToken(token_id); } @@ -3481,7 +3880,7 @@ class Client { /** * Builds a data deposit transaction without signing it. */ - async buildDataDeposit({ data }: DataDepositArgs): Promise { + async buildDataDeposit({ data }: DataDepositArgs): Promise { this.ensureInitialized(); return this.buildTransaction({ type: 'DataDeposit', params: { data } }); } @@ -3499,7 +3898,7 @@ class Client { /** * Builds a delegation creation transaction without signing it. */ - async buildDelegationCreate({ pool_id, destination }: DelegationCreateArgs): Promise { + async buildDelegationCreate({ pool_id, destination }: DelegationCreateArgs): Promise { this.ensureInitialized(); return this.buildTransaction({ type: 'CreateDelegationId', params: { pool_id, destination } }); } @@ -3518,7 +3917,7 @@ class Client { /** * Builds a delegation staking transaction without signing it. */ - async buildDelegationStake(params: DelegationStakeArgs): Promise { + async buildDelegationStake(params: DelegationStakeArgs): Promise { this.ensureInitialized(); const amount = params.amount; @@ -3532,6 +3931,7 @@ class Client { if (delegation_id) { return this.buildTransaction({ type: 'DelegateStaking', params: { delegation_id, amount } }); } else if (pool_id) { + this.validateRawId(pool_id, 'delegation stake', 'pool_id'); const data: DelegationDetails[] = await this.apiProvider.getPoolDelegations(pool_id).catch(() => { throw new Error('Failed to fetch delegation id'); }); @@ -3578,7 +3978,7 @@ class Client { /** * Builds a delegation withdrawal transaction without signing it. */ - async buildDelegationWithdraw(params: DelegationWithdrawArgs): Promise { + async buildDelegationWithdraw(params: DelegationWithdrawArgs): Promise { this.ensureInitialized(); const amount = params.amount; const delegation_id = params.delegation_id; @@ -3589,6 +3989,7 @@ class Client { } if (delegation_id) { + this.validateRawId(delegation_id, 'delegation withdraw', 'delegation_id'); const delegation_details: DelegationDetails = await this.apiProvider.getDelegation(delegation_id); return this.buildTransaction({ @@ -3596,6 +3997,7 @@ class Client { params: { delegation_id, amount, delegation_details }, }); } else if (pool_id) { + this.validateRawId(pool_id, 'delegation withdraw', 'pool_id'); const data = await this.apiProvider.getPoolDelegations(pool_id); const delegationIdMap: Record = data.reduce( @@ -3639,12 +4041,13 @@ class Client { /** * Builds an HTLC creation transaction without signing it. */ - async buildCreateHtlc(params: CreateHtlcArgs): Promise { + async buildCreateHtlc(params: CreateHtlcArgs): Promise { this.ensureInitialized(); let token_details: TokenDetails | undefined = undefined; - if(params.token_id){ + if (params.token_id) { + this.validateRawId(params.token_id, 'create htlc', 'token_id'); const token = await this.apiProvider.getToken(params.token_id); token_details = token; } @@ -3680,42 +4083,43 @@ class Client { /** * Builds an HTLC refund transaction without signing it. */ - async buildRefundHtlc(params: any): Promise { + /** + * Builds an HTLC claim transaction (refund after timelock, or spend with + * the secret) without signing it. The two flows differ only in which HTLC + * key receives the funds. + * @private + */ + private async buildHtlcClaim(params: any, keyField: 'refund_key' | 'spend_key'): Promise { this.ensureInitialized(); - const { transaction_id, utxo } = params; + const { transaction_id } = params; let useHtlcUtxo: any[] = []; if (transaction_id) { + this.validateTransactionId(transaction_id, 'HTLC lookup'); const transaction: TransactionJSONRepresentation = await this.apiProvider.getTransaction(transaction_id); - // @ts-ignore - const { created } = this.previewUtxoChange({ JSONRepresentation: { ...transaction } } as Transaction); - // @ts-ignore - useHtlcUtxo = created.filter(({utxo}) => utxo.type === 'Htlc') || null; + const { created } = this.previewUtxoChange({ JSONRepresentation: { ...transaction } } as AssembledTransaction); + useHtlcUtxo = created.filter(({ utxo }) => utxo.type === 'Htlc'); } let token_details = undefined; - if(useHtlcUtxo[0].utxo.value.type === 'TokenV1'){ + if (useHtlcUtxo[0].utxo.value.type === 'TokenV1') { token_details = await this.apiProvider.getToken(useHtlcUtxo[0].utxo.value.token_id); } return this.buildTransaction({ type: 'Transfer', params: { - to: useHtlcUtxo[0].utxo.htlc.refund_key, + to: useHtlcUtxo[0].utxo.htlc[keyField], amount: useHtlcUtxo[0].utxo.value.amount.decimal, - ...( - useHtlcUtxo[0].utxo.value.type === 'TokenV1' - ? { token_id: useHtlcUtxo[0].utxo.value.token_id } - : {} - ), + ...(useHtlcUtxo[0].utxo.value.type === 'TokenV1' ? { token_id: useHtlcUtxo[0].utxo.value.token_id } : {}), token_details, }, opts: { forceSpendUtxo: useHtlcUtxo, - } + }, }); } @@ -3730,45 +4134,17 @@ class Client { } /** - * Builds an HTLC spend transaction without signing it. + * Builds an HTLC refund transaction without signing it. */ - async buildSpendHtlc(params: any): Promise { - this.ensureInitialized(); - - const { transaction_id, utxo } = params; - - let useHtlcUtxo: any[] = []; - - if (transaction_id) { - const transaction: TransactionJSONRepresentation = await this.apiProvider.getTransaction(transaction_id); - // @ts-ignore - const { created } = this.previewUtxoChange({ JSONRepresentation: { ...transaction } } as Transaction); - // @ts-ignore - useHtlcUtxo = created.filter(({utxo}) => utxo.type === 'Htlc') || null; - } - - let token_details = undefined; - - if(useHtlcUtxo[0].utxo.value.type === 'TokenV1'){ - token_details = await this.apiProvider.getToken(useHtlcUtxo[0].utxo.value.token_id); - } + async buildRefundHtlc(params: any): Promise { + return this.buildHtlcClaim(params, 'refund_key'); + } - return this.buildTransaction({ - type: 'Transfer', - params: { - to: useHtlcUtxo[0].utxo.htlc.spend_key, - amount: useHtlcUtxo[0].utxo.value.amount.decimal, - ...( - useHtlcUtxo[0].utxo.value.type === 'TokenV1' - ? { token_id: useHtlcUtxo[0].utxo.value.token_id } - : {} - ), - token_details, - }, - opts: { - forceSpendUtxo: useHtlcUtxo, - } - }); + /** + * Builds an HTLC spend transaction without signing it. + */ + async buildSpendHtlc(params: any): Promise { + return this.buildHtlcClaim(params, 'spend_key'); } /** @@ -3796,11 +4172,12 @@ class Client { format = 'Uint8Array', // 'bytes' or 'hex' } = arg; + this.validateTransactionId(transaction_id, 'HTLC secret extraction'); const transaction: TransactionJSONRepresentation = await this.apiProvider.getTransaction(transaction_id); const transaction_signed = hexToUint8Array(transaction_hex); - const inputs = transaction.inputs.filter(({utxo}: any) => utxo && utxo.type === 'Htlc'); + const inputs = transaction.inputs.filter(({ utxo }: any) => utxo && utxo.type === 'Htlc'); const outpointedSourceIds: any[] = (inputs as any[]) .filter(({ input }) => input.input_type === 'UTXO') @@ -3815,14 +4192,9 @@ class Client { const htlc_outpoint_source_id: any = outpointedSourceIds[0].source_id; const htlc_output_index: any = outpointedSourceIds[0].index; - const secret = extract_htlc_secret( - transaction_signed, - true, - htlc_outpoint_source_id, - htlc_output_index - ); + const secret = extract_htlc_secret(transaction_signed, true, htlc_outpoint_source_id, htlc_output_index); - if(format === 'hex') { + if (format === 'hex') { return uint8ArrayToHex(secret); } @@ -3834,7 +4206,7 @@ class Client { * @param tx - The transaction to sign * @returns Promise that resolves to the signed transaction hex */ - async signTransaction(tx: Transaction): Promise { + async signTransaction(tx: AssembledTransaction): Promise { this.ensureInitialized(); return this.request({ method: 'signTransaction', @@ -3842,7 +4214,7 @@ class Client { }); } - async signIntentTransaction(tx: Transaction): Promise { + async signIntentTransaction(tx: AssembledTransaction): Promise { this.ensureInitialized(); return this.request({ method: 'signTransaction', @@ -3909,11 +4281,11 @@ class Client { * @param tx - The transaction to preview. * @return { spent: UtxoEntry[], created: UtxoEntry[] } An object containing arrays of spent and created UTXOs. */ - previewUtxoChange(tx: Transaction): { spent: UtxoEntry[]; created: UtxoEntry[] } { + previewUtxoChange(tx: AssembledTransaction): { spent: UtxoEntry[]; created: UtxoEntry[] } { const spent: UtxoEntry[] = []; const created: UtxoEntry[] = []; - tx.JSONRepresentation.inputs.forEach((input) => { + tx.JSONRepresentation.inputs.forEach((input: any) => { if (input.input.input_type === 'UTXO' && (input as UtxoInput).utxo) { spent.push({ outpoint: { @@ -3926,7 +4298,7 @@ class Client { } }); - tx.JSONRepresentation.outputs.forEach((output, index) => { + tx.JSONRepresentation.outputs.forEach((output: any, index: number) => { if (output.type === 'Transfer') { created.push({ outpoint: { @@ -4009,10 +4381,10 @@ class Client { const originalBuildTransaction = this.buildTransaction; - let txresult: Transaction | undefined = undefined; + let txresult: AssembledTransaction | undefined = undefined; this.buildTransaction = new Proxy(this.buildTransaction, { apply: async (target, thisArg, args) => { - const result = (await Reflect.apply(target, thisArg, args)) as Transaction; + const result = (await Reflect.apply(target, thisArg, args)) as AssembledTransaction; txresult = result; // pull the result of the buildTransaction return result; }, @@ -4058,7 +4430,7 @@ class Client { * @param tx - The transaction to broadcast (hex string or object with hex and json) * @returns Promise that resolves to the broadcast response */ - async broadcastTx(tx: string | { hex: string, json: TransactionJSONRepresentation }): Promise { + async broadcastTx(tx: string | { hex: string; json: TransactionJSONRepresentation }): Promise { this.ensureInitialized(); return this.apiProvider.broadcastTransaction(tx); } @@ -4100,39 +4472,57 @@ class Signer { return this.keys[address]; } - private createSignature(tx: Transaction) { + private createSignature(tx: AssembledTransaction) { const network = this.network; - const optUtxos_ = tx.JSONRepresentation.inputs.map((input) => { + const optUtxos_ = tx.JSONRepresentation.inputs.map((input: any) => { if (input.input.input_type !== 'UTXO') { - return 0 + return 0; } const { utxo }: UtxoInput = input as UtxoInput; if (input.input.input_type === 'UTXO') { if (utxo.type === 'Transfer') { if (utxo.value.type === 'TokenV1') { - return encode_output_token_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, utxo.value.token_id, network); + return encode_output_token_transfer( + Amount.from_atoms(utxo.value.amount.atoms), + utxo.destination, + utxo.value.token_id, + network, + ); } else { return encode_output_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, network); } } if (utxo.type === 'LockThenTransfer') { let lockEncoded: Uint8Array = new Uint8Array(); + const lockContent = utxo.lock.content; if (utxo.lock.type === 'UntilTime') { - // @ts-ignore - lockEncoded = encode_lock_until_time(BigInt(utxo.lock.content.timestamp)); // TODO: check if timestamp is correct + lockEncoded = encode_lock_until_time( + BigInt(typeof lockContent === 'object' ? lockContent.timestamp : lockContent), + ); // TODO: check if timestamp is correct } if (utxo.lock.type === 'ForBlockCount') { - lockEncoded = encode_lock_for_block_count(BigInt(utxo.lock.content)); + lockEncoded = encode_lock_for_block_count(BigInt(lockContent as string | number)); } if (utxo.value.type === 'TokenV1') { - return encode_output_token_lock_then_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, utxo.value.token_id, lockEncoded, network); + return encode_output_token_lock_then_transfer( + Amount.from_atoms(utxo.value.amount.atoms), + utxo.destination, + utxo.value.token_id, + lockEncoded, + network, + ); } else { - return encode_output_lock_then_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, lockEncoded, network); + return encode_output_lock_then_transfer( + Amount.from_atoms(utxo.value.amount.atoms), + utxo.destination, + lockEncoded, + network, + ); } } - return null + return null; } - }) + }); const optUtxosArray: number[] = []; @@ -4151,83 +4541,69 @@ class Signer { const optUtxos = new Uint8Array(optUtxosArray); - const encodedWitnesses = tx.JSONRepresentation.inputs.map( - (input, index) => { - let address: string | undefined = undefined; + const encodedWitnesses = tx.JSONRepresentation.inputs.map((input: any, index: number) => { + let address: string | undefined = undefined; - if (input.input.input_type === 'UTXO') { - const utxoInput = input as UtxoInput; - address = utxoInput.utxo.destination; - } + if (input.input.input_type === 'UTXO') { + const utxoInput = input as UtxoInput; + address = utxoInput.utxo.destination; + } - if (input.input.input_type === 'AccountCommand') { - // @ts-ignore - address = input.input.authority; - } + if (input.input.input_type === 'AccountCommand') { + // @ts-ignore + address = input.input.authority; + } - if (input.input.input_type === 'AccountCommand' && input.input.command === 'FillOrder') { - address = input.input.destination; - } + if (input.input.input_type === 'AccountCommand' && input.input.command === 'FillOrder') { + address = input.input.destination; + } - if (address === undefined) { - throw new Error(`Address not found for input at index ${index}`); - } + if (address === undefined) { + throw new Error(`Address not found for input at index ${index}`); + } - const addressPrivateKey = this.getPrivateKey(address); + const addressPrivateKey = this.getPrivateKey(address); - if (!addressPrivateKey) { - throw new Error(`Private key not found for address: ${address}`); - } + if (!addressPrivateKey) { + throw new Error(`Private key not found for address: ${address}`); + } - const transaction = this.hexToUint8Array(tx.HEXRepresentation_unsigned); + const transaction = hexToUint8Array(tx.HEXRepresentation_unsigned); - const block_height = 200000n; // TODO: Get the current block height - const additional_info = { - pool_info: {}, - order_info: {} - }; + const block_height = FEE_BLOCK_HEIGHT; + const additional_info = { + pool_info: {}, + order_info: {}, + }; - const witness = encode_witness( - SignatureHashType.ALL, - addressPrivateKey, - address, - transaction, - optUtxos, - index, - additional_info, - block_height, - network, - ); - return witness; - }, - ) + const witness = encode_witness( + SignatureHashType.ALL, + addressPrivateKey, + address, + transaction, + optUtxos, + index, + additional_info, + block_height, + network, + ); + return witness; + }); const signature = mergeUint8Arrays(encodedWitnesses); return signature; } - private hexToUint8Array(hex: string): Uint8Array { - if (hex.length % 2 !== 0) { - throw new Error("Hex string must have an even length"); - } - - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); - } - return bytes; - } - - private encodeSignedTransaction(tx: Transaction, signature: Uint8Array): string { - const transaction_signed = encode_signed_transaction( - this.hexToUint8Array(tx.HEXRepresentation_unsigned), - signature + private encodeSignedTransaction(tx: AssembledTransaction, signature: Uint8Array): string { + const transaction_signed = encode_signed_transaction(hexToUint8Array(tx.HEXRepresentation_unsigned), signature); + const transaction_signed_hex = transaction_signed.reduce( + (acc, byte) => acc + byte.toString(16).padStart(2, '0'), + '', ); - const transaction_signed_hex = transaction_signed.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), ''); return transaction_signed_hex; } - sign(tx: Transaction): string { + sign(tx: AssembledTransaction): string { const signature = this.createSignature(tx); return this.encodeSignedTransaction(tx, signature); } @@ -4236,12 +4612,4 @@ class Signer { export { Transaction } from './transaction'; export * from './wallet-state'; -export { - Client, - Signer, - PrivateKeyAccountProvider, - MnemonicAccountProvider, - MnemonicAccountProviderOptions, -}; - -console.log('[Mintlayer Connect SDK] Loaded'); +export { Client, Signer, PrivateKeyAccountProvider, MnemonicAccountProvider, MnemonicAccountProviderOptions }; diff --git a/packages/sdk/src/transaction.ts b/packages/sdk/src/transaction.ts index a0e17a4..afb859b 100644 --- a/packages/sdk/src/transaction.ts +++ b/packages/sdk/src/transaction.ts @@ -36,56 +36,282 @@ import { Network, TokenUnfreezable, TotalSupply, - decode_signed_transaction_to_js, } from '@mintlayer/wasm-lib'; import * as wasmLib from '@mintlayer/wasm-lib'; -import { mergeUint8Arrays, atomsToDecimal, stringToUint8Array } from './utils'; +import { mergeUint8Arrays, atomsToDecimal, stringToUint8Array, uint8ArrayToHex } from './utils'; import { UtxoEntry, UtxoInput } from './types/transaction'; +import type { LockThenTransferOutput, IssueNftOutput, Input, Output } from './types/transaction'; + +// Internal working state: `id`/`fee` are attached progressively during +// build/fromHEX, so the class works with the loose shape internally. +type TransactionJSON = any; type Utxo = any; -type Input = { - input: { - index: number; - input_type: 'UTXO'; - source_id: string; - source_type: SourceId; - }; - utxo: Utxo; -}; +/** + * The single fee-rate constant used by every build path. There is no live + * feerate source yet — see the TODO in the assembler — so both the fluent + * builder and the raw assembler MUST use this same value to stay in sync. + */ +export const FEE_AMOUNT_PER_KB = BigInt('100000000000'); + +/** + * Block height used for height-dependent protocol fees and issuance + * encodings when the caller has not set one. The chain rejects stale fee + * heights below the current tip, so this is kept comfortably ahead; the + * raw assembler has always used it, and it is the default for the fluent + * builder when no block height was passed to the constructor. + */ +export const FEE_BLOCK_HEIGHT = 200000n; + +/** + * Everything the assembler needs that does not belong to the transaction + * itself: which network we are on and where change outputs go. + */ +export interface AssembleEnvironment { + network: 'mainnet' | 'testnet'; + changeAddress: string; + currentBlockHeight?: number | string | bigint; +} + +/** + * A fully normalized transaction plan — canonical inputs/outputs plus the + * computed coin/token requirements — ready for UTXO selection, the fee + * loop and encoding. Produced by the Client's raw-argument preparation or + * by the fluent builder's declared outputs. + */ +export interface PreparedTransaction { + outputs: Output[]; + inputs: Input[]; + requiredCoin: bigint; + requiredToken: bigint; + sendToken?: { token_id: string; number_of_decimals: number }; + baseFee: bigint; + deductFeeFromFirstOutput?: boolean; + withUTXO?: Utxo[]; + forceSpendUtxo?: Utxo[]; +} + +export interface AssembledTransactionData { + JSONRepresentation: TransactionJSON; + BINRepresentation: { inputs: Uint8Array[]; outputs: Uint8Array[]; transactionsize: number }; + HEXRepresentation_unsigned: string; + transaction_id: string; +} + +const MAX_FEE_ATTEMPTS = 10; + +// ── Shared assembly core ────────────────────────────────────────────────────── +// One implementation of UTXO selection, input encoding, fee convergence and +// transaction encoding — used by both the fluent builder (build()) and the +// raw assembler (Transaction.assembleRaw). Previously these existed twice +// (class + Client) and had already drifted apart. + +/** + * Hardened UTXO selection: NFT UTXOs are treated as spendable 1-unit token + * outputs, HTLC UTXOs must be spent manually and are filtered out, and an + * extra UTXO is appended when the selection exactly matches the target so + * the fee can still grow during convergence. + */ +function selectUTXOsFor(utxos: UtxoEntry[], amount: bigint, token_id: string | null): UtxoInput[] { + // HTLC outputs are excluded: spending them requires the preimage (secret), + // so they must be claimed explicitly via forceSpendUtxo, never auto-selected. + const transferableUtxoTypes = ['Transfer', 'LockThenTransfer', 'IssueNft']; + const filteredUtxos: any[] = utxos + .map((utxo) => { + if (utxo.utxo.type === 'IssueNft') { + return { + ...utxo, + utxo: { + ...utxo.utxo, + value: { + amount: { atoms: 1, decimal: 1 }, + type: 'TokenV1', + token_id: utxo.utxo.token_id, + }, + }, + }; + } + return utxo; + }) + .filter((utxo) => transferableUtxoTypes.includes(utxo.utxo.type)) + .filter((utxo) => { + if (utxo.utxo.type === 'IssueNft') { + return utxo.utxo.token_id === token_id; + } + if (token_id === null) { + return utxo.utxo.value.type === 'Coin'; + } + if (utxo.utxo.value.type === 'TokenV1') { + return utxo.utxo.value.token_id === token_id; + } + return false; + }); -type Output = any; + let balance = BigInt(0); + const utxosToSpend: UtxoEntry[] = []; + let lastIndex = 0; + + filteredUtxos.sort((a, b) => Number(BigInt(b.utxo.value.amount.atoms) - BigInt(a.utxo.value.amount.atoms))); + + for (let i = 0; i < filteredUtxos.length; i++) { + lastIndex = i; + const utxoBalance = BigInt(filteredUtxos[i].utxo.value.amount.atoms); + if (balance < amount) { + balance += utxoBalance; + utxosToSpend.push(filteredUtxos[i]); + } else { + break; + } + } + + if (balance === amount && filteredUtxos[lastIndex + 1]) { + utxosToSpend.push(filteredUtxos[lastIndex + 1]); + } + + return utxosToSpend.map((item: UtxoEntry) => ({ + input: { ...item.outpoint, input_type: 'UTXO' }, + utxo: item.utxo, + })); +} + +/** + * Encodes the transaction inputs (account commands first, then UTXO inputs). + * Input bytes do not depend on the outputs, so this can be used standalone — + * e.g. to derive the token id of an IssueNft output before encoding it. + */ +function getTransactionInputsBytesFor( + transactionJSONrepresentation: TransactionJSON, + network: Network, + blockHeight: bigint, +): Uint8Array[] { + const inputs = transactionJSONrepresentation.inputs; + + const inputsIds = (inputs as UtxoInput[]) + .filter(({ input }) => input.input_type === 'UTXO') + .map(({ input }) => { + const bytes = Uint8Array.from(input.source_id.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); + return encode_input_for_utxo(encode_outpoint_source_id(bytes, SourceId.Transaction), input.index); + }); + + const inputCommands = (inputs as any[]) + .filter(({ input }) => input.input_type === 'AccountCommand' || input.input_type === 'Account') + .map(({ input }) => { + if (input.command === 'ConcludeOrder') { + return encode_input_for_conclude_order(input.order_id, BigInt(input.nonce), blockHeight, network); + } + if (input.command === 'FillOrder') { + return encode_input_for_fill_order( + input.order_id, + Amount.from_atoms(input.fill_atoms), + input.destination, + BigInt(input.nonce), + blockHeight, + network, + ); + } + if (input.command === 'MintTokens') { + return encode_input_for_mint_tokens( + input.token_id, + Amount.from_atoms(input.amount.atoms), + BigInt(input.nonce), + network, + ); + } + if (input.command === 'UnmintTokens') { + return encode_input_for_unmint_tokens(input.token_id, BigInt(input.nonce), network); + } + if (input.command === 'LockTokenSupply') { + return encode_input_for_lock_token_supply(input.token_id, BigInt(input.nonce), network); + } + if (input.command === 'ChangeTokenAuthority') { + return encode_input_for_change_token_authority( + input.token_id, + input.new_authority, + BigInt(input.nonce), + network, + ); + } + if (input.command === 'ChangeMetadataUri') { + return encode_input_for_change_token_metadata_uri( + input.token_id, + input.new_metadata_uri, + BigInt(input.nonce), + network, + ); + } + if (input.command === 'FreezeToken') { + return encode_input_for_freeze_token( + input.token_id, + input.is_unfreezable ? TokenUnfreezable.Yes : TokenUnfreezable.No, + BigInt(input.nonce), + network, + ); + } + if (input.command === 'UnfreezeToken') { + return encode_input_for_unfreeze_token(input.token_id, BigInt(input.nonce), network); + } + if (input.account_type === 'DelegationBalance') { + return encode_input_for_withdraw_from_delegation( + input.delegation_id, + Amount.from_atoms(input.amount.atoms), + BigInt(input.nonce), + network, + ); + } + return undefined; + }) + .filter((x): x is Uint8Array => x !== undefined); -type TransactionJSONRepresentation = any; + return [...inputCommands, ...inputsIds]; +} -type Client = any; +/** + * Shared fee-convergence tail: encode the candidate transaction at + * `preciseFee`, estimate its size and derive the next fee. Returns the + * encoded transaction once the fee stops moving. + */ +function convergeFeeAndEncode( + BINRepresentation: { inputs: Uint8Array[]; outputs: Uint8Array[]; transactionsize: number }, + previousFee: bigint, + preciseFee: bigint, +): { converged: boolean; transaction?: Uint8Array; transaction_id?: string; nextPreciseFee: bigint } { + const tx_size = BigInt(Math.ceil(BINRepresentation.transactionsize)); + // TODO: fetch live feerate + const nextPreciseFee = (FEE_AMOUNT_PER_KB * tx_size + 999n) / 1000n; + + if (nextPreciseFee === preciseFee || nextPreciseFee === previousFee) { + const transaction = encode_transaction( + mergeUint8Arrays(BINRepresentation.inputs), + mergeUint8Arrays(BINRepresentation.outputs), + BigInt(0), + ); + return { converged: true, transaction, transaction_id: get_transaction_id(transaction, true), nextPreciseFee }; + } + return { converged: false, nextPreciseFee }; +} export class Transaction { - private inputs: Input[] - private outputs: Output[] - private fee: bigint - private utxos: Utxo[] - private transactionId: string - private hexRepresentation: string - 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 + private outputs: Output[]; + private fee: bigint; + private utxos: Utxo[]; + private transactionId: string; + private hexRepresentation: string; + private binRepresentation: { inputs: Uint8Array[]; outputs: Uint8Array[]; transactionsize: number } | null; + private jsonRepresentation: TransactionJSON; + private currentBlockHeight: number; + private network: 'mainnet' | 'testnet'; + private changeAddress: string; constructor({ - client, network, currentBlockHeight, }: { - client?: Client; network?: 'mainnet' | 'testnet'; currentBlockHeight?: number | string | bigint; } = {}) { - this.inputs = []; this.outputs = []; this.utxos = []; this.transactionId = ''; @@ -96,10 +322,6 @@ export class Transaction { this.network = network ?? 'testnet'; this.fee = BigInt(0); this.changeAddress = ''; - - if (client) { - this.client = client; - } } setChangeAddress(address: string) { @@ -152,25 +374,11 @@ export class Transaction { return this; } - setCurrentBlockHeight(height: number | string | bigint) { - this.currentBlockHeight = Number(height); - return this; - } - - addInput(input: Input) { - this.inputs.push(input); - return this; - } - addOutput(output: Output) { this.outputs.push(output); return this; } - addAction(action: any) { - return this; - } - withUTXO(utxos: Utxo | Utxo[]) { this.utxos = Array.isArray(utxos) ? utxos : [utxos]; return this; @@ -218,12 +426,8 @@ export class Transaction { return transaction.fromHEX(hex); } - getTransactionId() { - return this.transactionId; - } - // Signer-compatibility getters (match the shape used by Signer.sign()) - get JSONRepresentation(): TransactionJSONRepresentation { + get JSONRepresentation(): TransactionJSON { return this.jsonRepresentation; } get BINRepresentation() { @@ -236,12 +440,17 @@ export class Transaction { return this.transactionId; } + /** + * Fluent builder: assembles from previously added outputs and UTXOs and + * returns `this` for in-process fluent use. For transport-safe plain data + * (wallet bridge / JSON), use the static {@link assembleRaw} instead. + */ build() { - if (!this.client && !this.utxos.length) { - throw new Error('Client or UTXOs are required to build transaction'); + if (!this.utxos.length) { + throw new Error('UTXOs are required to build transaction'); } - if (!this.client && !this.changeAddress) { - throw new Error('Client or Change Address are required to build transaction'); + if (!this.changeAddress) { + throw new Error('A change address is required to build transaction'); } const declaredOutputs: Output[] = [...this.outputs]; @@ -255,10 +464,7 @@ export class Transaction { 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), - ); + token_reqs.set(val.token_id, (token_reqs.get(val.token_id) ?? 0n) + BigInt(val.amount.atoms)); } } @@ -266,17 +472,13 @@ export class Transaction { let preciseFee = 0n; let previousFee = -1n; - const MAX_ATTEMPTS = 10; - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + for (let attempt = 0; attempt < MAX_FEE_ATTEMPTS; attempt++) { const totalFee = preciseFee; 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, - ); + const coinInputs = selectUTXOsFor(this.utxos as UtxoEntry[], 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'); } @@ -284,11 +486,8 @@ export class Transaction { 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, - ); + const tInputs = selectUTXOsFor(this.utxos as UtxoEntry[], 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}`); } @@ -330,7 +529,7 @@ export class Transaction { const finalInputs: Input[] = [...coinInputs, ...tokenInputsAll]; - const JSONRepresentation: TransactionJSONRepresentation = { + const JSONRepresentation: TransactionJSON = { inputs: finalInputs, outputs: finalOutputs, fee: { @@ -340,27 +539,23 @@ export class Transaction { id: 'to_be_filled_in', }; - const BINRepresentation = this.getTransactionBINrepresentation(JSONRepresentation, networkId); - - 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; + const BINRepresentation = this.getTransactionBINrepresentation( + JSONRepresentation, + networkId, + Number(this.currentBlockHeight || FEE_BLOCK_HEIGHT), + ); - if (nextPreciseFee === preciseFee || nextPreciseFee === previousFee) { - const transaction = encode_transaction( - mergeUint8Arrays(BINRepresentation.inputs), - mergeUint8Arrays(BINRepresentation.outputs), - BigInt(0), - ); - const transaction_id = get_transaction_id(transaction, true); + const { converged, transaction, transaction_id, nextPreciseFee } = convergeFeeAndEncode( + BINRepresentation, + previousFee, + preciseFee, + ); + if (converged && transaction && 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.hexRepresentation = transaction.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), ''); this.jsonRepresentation = { ...JSONRepresentation, id: transaction_id }; return this; } @@ -369,205 +564,302 @@ export class Transaction { preciseFee = nextPreciseFee; } - throw new Error('Failed to build transaction after maximum attempts'); + throw new Error( + `Fee did not converge after ${MAX_FEE_ATTEMPTS} attempts (last fee ${preciseFee} atoms, previous ${previousFee})`, + ); } hex() { return this.hexRepresentation; } - json(): TransactionJSONRepresentation { - return this.jsonRepresentation; - } - - getFee() { - return { - atoms: this.fee.toString(), - decimal: atomsToDecimal(this.fee.toString(), 11).toString(), - }; - } - /** - * Select required UTXOs for the transaction based on provided amount and token ID. - * @param utxos - * @param amount - * @param token_id - * @private + * Assembles a transaction from a fully prepared plan (canonical inputs and + * outputs plus computed coin/token requirements). This is the ONE + * assembly engine: the Client's `buildRawTransaction`/`buildTransaction` + * normalize their arguments into a `PreparedTransaction` and delegate + * here; the fluent `build()` shares the same fee-convergence and encoding + * core. + * + * Returns plain assembled data (`AssembledTransactionData`) rather than a + * class instance so the result survives structured-clone/JSON transport + * (the wallet bridge receives it as `txData`). */ - private selectUTXOs(utxos: UtxoEntry[], amount: bigint, token_id: string | null): UtxoInput[] { - const transferableUtxoTypes = ['Transfer', 'LockThenTransfer', 'IssueNft', 'Htlc']; - const filteredUtxos: any[] = utxos // type fix for NFT considering that NFT don't have amount - .map((utxo) => { - if (utxo.utxo.type === 'IssueNft') { - return { - ...utxo, - utxo: { - ...utxo.utxo, + static assembleRaw(prepared: PreparedTransaction, env: AssembleEnvironment): AssembledTransactionData { + const { + outputs, + inputs = [], + requiredCoin, + requiredToken, + sendToken, + baseFee, + deductFeeFromFirstOutput = false, + withUTXO, + forceSpendUtxo, + } = prepared; + + if (!env.changeAddress) { + throw new Error('A change address is required to assemble a transaction'); + } + + const networkId = env.network === 'mainnet' ? 0 : 1; + // `||` on purpose: an explicit 0 is as unusable as unset (the chain + // rejects stale fee heights), matching the fluent builder's fallback. + const blockHeight = BigInt(env.currentBlockHeight || FEE_BLOCK_HEIGHT); + + let data_utxos: UtxoEntry[]; + if (withUTXO) { + data_utxos = withUTXO as UtxoEntry[]; + } else { + throw new Error('UTXOs are required: provide them via withUTXO'); + } + + const utxos: UtxoEntry[] = data_utxos.filter((item: UtxoEntry) => { + if (!item.utxo) { + return false; + } + // HTLC UTXOs have to be added manually + if (item.utxo.type === 'Htlc') { + return false; + } + return true; + }); + + // Forced entries in the SAME shape the selector emits ({input, utxo}) — + // mixing shapes here used to crash input encoding downstream. + const forcedUtxos: UtxoInput[] = (forceSpendUtxo ?? []).map((item: UtxoEntry) => ({ + input: { ...item.outpoint, input_type: 'UTXO' }, + utxo: item.utxo, + })); + // An outpoint listed as forced must not also be auto-selected (double + // spend inside one transaction + double-counted change math). + const forcedOutpointKeys = new Set(forcedUtxos.map(({ input }) => `${input.source_id}:${input.index}`)); + const isForced = (entry: UtxoInput) => forcedOutpointKeys.has(`${entry.input.source_id}:${entry.input.index}`); + // A caller-supplied duplicate inside forceSpendUtxo itself is dropped too. + const seenForced = new Set(); + const dedupedForcedUtxos = forcedUtxos.filter(({ input }) => { + const key = `${input.source_id}:${input.index}`; + if (seenForced.has(key)) return false; + seenForced.add(key); + return true; + }); + + let preciseFee = BigInt(0); + let previousFee = BigInt(-1); + + // Snapshot the pre-loop amount of the fee-deducted output: the deduction + // must be relative to the original value in every fee iteration, not + // compounded onto the value mutated by the previous iteration. (Amount + // encoding is variable-length, so the fee CAN change between iterations.) + const firstOutputOriginalAtoms = deductFeeFromFirstOutput + ? BigInt((outputs[0] as LockThenTransferOutput).value.amount.atoms) + : 0n; + + for (let attempt = 0; attempt < MAX_FEE_ATTEMPTS; attempt++) { + const totalFee = baseFee + preciseFee; + const input_amount_coin_req_w_fee = requiredCoin + totalFee; + + const inputObjCoin = deductFeeFromFirstOutput + ? [] + : selectUTXOsFor(utxos, input_amount_coin_req_w_fee, null).filter((entry) => !isForced(entry)); + const inputObjToken = sendToken?.token_id + ? selectUTXOsFor(utxos, requiredToken, sendToken.token_id).filter((entry) => !isForced(entry)) + : []; + + if (dedupedForcedUtxos.length > 0) { + const forceCoinUtxos = dedupedForcedUtxos.filter((utxo) => utxo.utxo.value.type === 'Coin'); + const forceTokenUtxos = dedupedForcedUtxos.filter( + (utxo) => utxo.utxo.value.type === 'TokenV1' && utxo.utxo.value.token_id === sendToken?.token_id, + ); + if (forceCoinUtxos.length > 0) { + inputObjCoin.unshift(...forceCoinUtxos); + } + if (forceTokenUtxos.length > 0) { + inputObjToken.unshift(...forceTokenUtxos); + } + } + + const totalInputValueCoin = inputObjCoin.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); + const totalInputValueToken = inputObjToken.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); + + if (!deductFeeFromFirstOutput && totalInputValueCoin < input_amount_coin_req_w_fee) { + throw new Error( + `Not enough coin UTXOs: required ${input_amount_coin_req_w_fee} atoms (outputs + fee), selected ${totalInputValueCoin}`, + ); + } + if (totalInputValueToken < requiredToken) { + throw new Error( + `Not enough token UTXOs for ${sendToken?.token_id}: required ${requiredToken} atoms, selected ${totalInputValueToken}`, + ); + } + + // When the fee is deducted from the first output it must NOT also be + // subtracted from the coin change — forced coin inputs would otherwise + // be double-charged (inputs - outputs != fee). + const changeAmountCoin = deductFeeFromFirstOutput + ? totalInputValueCoin - requiredCoin + : totalInputValueCoin - input_amount_coin_req_w_fee; + const changeAmountToken = totalInputValueToken - requiredToken; + + // Fresh objects — never mutate the caller's prepared outputs (the + // fee-deducted output is cloned deeply: value/amount are written below). + const finalOutputs: Output[] = outputs.map((output, outputIndex) => + deductFeeFromFirstOutput && outputIndex === 0 + ? { + ...output, value: { - amount: { - atoms: 1, - decimal: 1, - }, - type: 'TokenV1', - token_id: utxo.utxo.token_id, + ...(output as LockThenTransferOutput).value, + amount: { ...(output as LockThenTransferOutput).value.amount }, }, - }, - }; - } else { - return utxo; - } - }) - .filter((utxo) => transferableUtxoTypes.includes(utxo.utxo.type)) - .filter((utxo) => { - if (utxo.utxo.type === 'IssueNft') { - return utxo.utxo.token_id === token_id; - } else { - if (token_id === null) { - return utxo.utxo.value.type === 'Coin'; - } - if (utxo.utxo.value.type === 'TokenV1') { - return utxo.utxo.value.token_id === token_id; - } + } + : { ...output }, + ); + if (deductFeeFromFirstOutput) { + const out = finalOutputs[0] as LockThenTransferOutput; + const netAtoms = firstOutputOriginalAtoms - totalFee; + if (netAtoms < 0n) { + throw new Error('DelegationWithdraw amount is smaller than the transaction fee'); } - }); + out.value.amount = { + atoms: netAtoms.toString(), + decimal: atomsToDecimal(netAtoms.toString(), 11), + }; + } - let balance = BigInt(0); - const utxosToSpend: UtxoEntry[] = []; - let lastIndex = 0; + if (changeAmountCoin > 0n) { + finalOutputs.push({ + type: 'Transfer', + value: { + type: 'Coin', + amount: { + atoms: changeAmountCoin.toString(), + decimal: atomsToDecimal(changeAmountCoin.toString(), 11).toString(), + }, + }, + destination: env.changeAddress, + }); + } - filteredUtxos.sort((a, b) => { - return Number(BigInt(b.utxo.value.amount.atoms) - BigInt(a.utxo.value.amount.atoms)); - }); + if (changeAmountToken > 0n && sendToken) { + finalOutputs.push({ + type: 'Transfer', + value: { + type: 'TokenV1', + token_id: sendToken.token_id, + amount: { + atoms: changeAmountToken.toString(), + decimal: atomsToDecimal(changeAmountToken.toString(), sendToken.number_of_decimals).toString(), + }, + }, + destination: env.changeAddress, + }); + } - for (let i = 0; i < filteredUtxos.length; i++) { - lastIndex = i; - const utxoBalance = BigInt(filteredUtxos[i].utxo.value.amount.atoms); - if (balance < amount) { - balance += utxoBalance; - utxosToSpend.push(filteredUtxos[i]); - } else { - break; + const finalInputs: Input[] = [...inputs, ...inputObjCoin, ...inputObjToken]; + + const JSONRepresentation: TransactionJSON = { + inputs: finalInputs, + outputs: finalOutputs, + fee: { + atoms: totalFee.toString(), + decimal: atomsToDecimal(totalFee.toString(), 11).toString(), + }, + id: 'to_be_filled_in', + }; + + // IssueNft outputs need their token id before encoding (the wasm encoder + // rejects a placeholder id). The id is derived from the encoded inputs, + // which do not depend on the outputs, so it can be computed up front. + const issueNftIndexes = finalOutputs.reduce( + (acc, output, index) => (output.type === 'IssueNft' ? [...acc, index] : acc), + [], + ); + if (issueNftIndexes.length > 1) { + throw new Error( + 'Only one IssueNft output per transaction is supported: the token id is derived from the transaction inputs, so multiple issuances need separate transactions', + ); + } + if (issueNftIndexes.length === 1) { + const token_id = get_token_id( + mergeUint8Arrays(getTransactionInputsBytesFor(JSONRepresentation, networkId, blockHeight)), + blockHeight, + env.network === 'mainnet' ? Network.Mainnet : Network.Testnet, + ); + const index = issueNftIndexes[0]; + const output = finalOutputs[index] as IssueNftOutput; + if (output.token_id && output.token_id !== token_id) { + throw new Error( + `IssueNft token_id mismatch: the transaction inputs derive ${token_id}, but the caller supplied ${output.token_id}`, + ); + } + finalOutputs[index] = { + ...output, + token_id, + }; } - } - if (balance === amount) { - if (filteredUtxos[lastIndex + 1]) { - utxosToSpend.push(filteredUtxos[lastIndex + 1]); + const BINRepresentation = new Transaction({ network: env.network }).getTransactionBINrepresentation( + JSONRepresentation, + networkId, + Number(blockHeight), + ); + + const { converged, transaction, transaction_id, nextPreciseFee } = convergeFeeAndEncode( + BINRepresentation, + previousFee, + preciseFee, + ); + + if (converged && transaction && transaction_id) { + return { + JSONRepresentation: { + ...JSONRepresentation, + id: transaction_id, + }, + BINRepresentation, + HEXRepresentation_unsigned: transaction.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), ''), + transaction_id, + }; } + + previousFee = preciseFee; + preciseFee = nextPreciseFee; } - const transformedInput: UtxoInput[] = utxosToSpend.map((item: UtxoEntry) => ({ - input: { - ...item.outpoint, - input_type: 'UTXO', - }, - utxo: item.utxo, - })); + throw new Error('Failed to build transaction after maximum attempts'); + } + + json(): TransactionJSON { + return this.jsonRepresentation; + } - return transformedInput; + getFee() { + return { + atoms: this.fee.toString(), + decimal: atomsToDecimal(this.fee.toString(), 11).toString(), + }; } /** - * Returns the transaction binary representation. - * @param transactionJSONrepresentation - * @param _network + * Returns the transaction binary representation (encoded inputs/outputs + * plus the size estimate used for fee computation). + * @param transactionJSONrepresentation - explorer-style JSON + * @param _network - wasm Network enum value (0 mainnet / 1 testnet) + * @param blockHeight - height for height-dependent encodings; defaults to + * the instance value or FEE_BLOCK_HEIGHT when unset */ getTransactionBINrepresentation( - transactionJSONrepresentation: TransactionJSONRepresentation, + transactionJSONrepresentation: TransactionJSON, _network: Network, + blockHeight: number = this.currentBlockHeight || Number(FEE_BLOCK_HEIGHT), ): { inputs: Uint8Array[]; outputs: Uint8Array[]; transactionsize: number; } { const network = _network; - // Binarisation - // calculate fee and prepare as much transaction as possible - const inputs = transactionJSONrepresentation.inputs; - const outpointedSourceIds = (inputs as UtxoInput[]) - .filter(({ input }) => input.input_type === 'UTXO') - .map(({ input }) => { - const bytes = Uint8Array.from(input.source_id.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); - return { - source_id: encode_outpoint_source_id(bytes, SourceId.Transaction), - index: input.index, - }; - }); - const inputsIds = outpointedSourceIds.map((source) => encode_input_for_utxo(source.source_id, source.index)); - - const inputCommands = (transactionJSONrepresentation.inputs as any[]) - .filter(({ input }) => input.input_type === 'AccountCommand' || input.input_type === 'Account') - .map(({ input }) => { - if (input.command === 'ConcludeOrder') { - return encode_input_for_conclude_order( - input.order_id, - BigInt(input.nonce.toString()), - BigInt(this.currentBlockHeight), - network, - ); - } - if (input.command === 'FillOrder') { - return encode_input_for_fill_order( - input.order_id, - Amount.from_atoms(input.fill_atoms.toString()), - input.destination, - BigInt(input.nonce.toString()), - BigInt(this.currentBlockHeight), - network, - ); - } - if (input.command === 'MintTokens') { - return encode_input_for_mint_tokens( - input.token_id, - Amount.from_atoms(input.amount.atoms.toString()), - input.nonce.toString(), - network, - ); - } - if (input.command === 'UnmintTokens') { - return encode_input_for_unmint_tokens(input.token_id, input.nonce.toString(), network); - } - if (input.command === 'LockTokenSupply') { - return encode_input_for_lock_token_supply(input.token_id, input.nonce.toString(), network); - } - if (input.command === 'ChangeTokenAuthority') { - return encode_input_for_change_token_authority( - input.token_id, - input.new_authority, - input.nonce.toString(), - network, - ); - } - if (input.command === 'ChangeMetadataUri') { - return encode_input_for_change_token_metadata_uri( - input.token_id, - input.new_metadata_uri, - input.nonce.toString(), - network, - ); - } - if (input.command === 'FreezeToken') { - return encode_input_for_freeze_token( - input.token_id, - input.is_unfreezable ? TokenUnfreezable.Yes : TokenUnfreezable.No, - input.nonce.toString(), - network, - ); - } - if (input.command === 'UnfreezeToken') { - return encode_input_for_unfreeze_token(input.token_id, input.nonce.toString(), network); - } - if (input.account_type === 'DelegationBalance') { - return encode_input_for_withdraw_from_delegation( - input.delegation_id, - Amount.from_atoms(input.amount.atoms.toString()), - BigInt(input.nonce.toString()), - network, - ); - } - }); - - const inputsArray = [...inputCommands, ...inputsIds].filter((x): x is NonNullable => x !== undefined); + // Binarisation: account commands first, then UTXO inputs (shared core). + const inputsArray = getTransactionInputsBytesFor(transactionJSONrepresentation, network, BigInt(blockHeight)); const outputsArrayItems = transactionJSONrepresentation.outputs.map((output: any) => { if (output.type === 'Transfer') { @@ -638,10 +930,7 @@ export class Transaction { const { destination: address, token_id } = output; - if (!this.currentBlockHeight) { - throw new Error('currentBlockHeight is required for IssueNft — call setCurrentBlockHeight(...)'); - } - const chainTip = this.currentBlockHeight; + const chainTip = BigInt(blockHeight); return encode_output_issue_nft( token_id as string, @@ -661,10 +950,7 @@ export class Transaction { if (output.type === 'IssueFungibleToken') { const { authority, is_freezable, metadata_uri, number_of_decimals, token_ticker, total_supply } = output; - if (!this.currentBlockHeight) { - throw new Error('currentBlockHeight is required for IssueFungibleToken — call setCurrentBlockHeight(...)'); - } - const chainTip = this.currentBlockHeight; + const chainTip = BigInt(blockHeight); const is_token_freezable = is_freezable ? FreezableToken.Yes : FreezableToken.No; @@ -728,19 +1014,23 @@ export class Transaction { }); const outputsArray = outputsArrayItems.filter((x: any): x is NonNullable => x !== undefined); - const inputAddresses: string[] = (transactionJSONrepresentation.inputs as UtxoInput[]) - // @ts-ignore + const inputAddresses: string[] = (transactionJSONrepresentation.inputs as any[]) .filter(({ input, utxo }) => input.input_type === 'UTXO' || utxo?.htlc) - .map((input) => { - if (input.utxo.destination){ - return input.utxo.destination; + .map((entry: any) => { + // decoded transactions carry no utxo payload — size estimation then + // falls back to the account-command destinations below + if ((entry as any).utxo?.destination) { + return (entry as any).utxo.destination; } - // @ts-ignore - if (input?.utxo?.htlc) { - // @ts-ignore - return [input.utxo.htlc.spend_key, input.utxo.htlc.refund_key]; // TODO: need to handle spend too + if ((entry as any)?.utxo?.htlc) { + // HTLC inputs are owned by BOTH keys — they must count toward the + // witness-size estimate or the fee underprices the transaction + return [(entry as any).utxo.htlc.spend_key, (entry as any).utxo.htlc.refund_key]; // TODO: need to handle spend too } - }).flat(); + return undefined; + }) + .filter((x): x is string | string[] => x !== undefined) + .flat(); const firstInput = transactionJSONrepresentation.inputs[0]?.input as any; @@ -815,66 +1105,267 @@ export class Transaction { transferNft(destination: string, token_id: string): Output { return this.transferToken(destination, '1', token_id); } +} + +// ── wasm decode → explorer-style JSON ───────────────────────────────────────── +// +// decode_transaction_to_js returns canonical chain types as tagged unions with +// untyped payloads (its TS declaration is `any`), so this mapper is pinned by +// fixtures generated from the real wasm output — see tests/transaction-decode +// .test.ts (byte-identical round-trips per type). Shapes were captured with +// encode_* → decode_transaction_to_js probes against @mintlayer/wasm-lib 1.4.0. + +const decodedBytesToHex = (bytes: number[] | Uint8Array): string => uint8ArrayToHex(new Uint8Array(bytes)); + +/** + * Amounts decode as `{atoms}`; the explorer-style shape wants + * `{atoms, decimal}`. Coin decimals are fixed at 11 so the decimal is + * recomputed exactly. Token decimals are NOT recoverable at decode time, so + * token amounts carry the atoms string as the decimal placeholder (same + * convention as `transferToken`/`build()` token change) — consumers format + * via token metadata. + */ +const decodedCoinAmount = (atoms: string): { atoms: string; decimal: string } => ({ + atoms, + decimal: atomsToDecimal(atoms, 11), +}); + +const decodedTokenAmount = (atoms: string): { atoms: string; decimal: string } => ({ + atoms, + decimal: atoms, +}); + +/** `{Coin:{atoms}} | {TokenV1:[token_id,{atoms}]}` → explorer-style Value. */ +const decodedValue = (value: any): any => { + if (!value || typeof value !== 'object') { + throw new Error('Decode: missing value'); + } + if (value.Coin) { + return { type: 'Coin', amount: decodedCoinAmount(value.Coin.atoms) }; + } + if (value.TokenV1) { + const [token_id, amount] = value.TokenV1; + return { type: 'TokenV1', token_id, amount: decodedTokenAmount(amount.atoms) }; + } + throw new Error(`Decode: unsupported value type ${Object.keys(value)[0]}`); +}; + +const decodedBytesField = (bytes: number[] | Uint8Array): { hex: string; string: string } => { + const string_ = new TextDecoder().decode(new Uint8Array(bytes)); + return { hex: decodedBytesToHex(bytes), string: string_ }; +}; - // actions - stakingWithdraw() { +const decodedInput = (input: any, index: number): any => { + if (input.Utxo) { return { - type: 'StakingWithdraw', - params: { - delegation_id: '', - amount: 0, + input: { + // the OUTPOINT index from the decode — NOT the input's array position + index: input.Utxo.index, + input_type: 'UTXO', + source_id: input.Utxo.id.Transaction, + source_type: 'Transaction', }, }; } -} -const decoded_to_json_representation = (decoded: any) => { - const tx = decoded.V1; + if (input.AccountCommand) { + const [nonce, command] = input.AccountCommand; + const tag = Object.keys(command)[0]; + const payload = command[tag]; + // The payload layout is command-specific; extract with tolerant accessors + // so unknown extra fields can never crash a display path. + const first = Array.isArray(payload) ? payload[0] : payload; + const second = Array.isArray(payload) ? payload[1] : undefined; + + const base: any = { + input_type: 'AccountCommand', + command: tag === 'ChangeTokenMetadataUri' ? 'ChangeMetadataUri' : tag, + nonce: Number(nonce), + }; - return { - inputs: tx.inputs.map((input: any, index: any) => { - if (input.Utxo) { + switch (tag) { + case 'MintTokens': + base.token_id = first; + base.amount = decodedTokenAmount(second.atoms); + break; + case 'UnmintTokens': + // decodes as a bare token-id string (no amount payload) + base.token_id = payload; + break; + case 'LockTokenSupply': + case 'UnfreezeToken': + base.token_id = payload; + break; + case 'FreezeToken': + base.token_id = first; + base.is_unfreezable = second === 'Yes'; + break; + case 'ChangeTokenAuthority': + base.token_id = first; + base.new_authority = second; + break; + case 'ChangeTokenMetadataUri': + base.token_id = first; + base.new_metadata_uri = new TextDecoder().decode(new Uint8Array(second)); + break; + case 'FillOrder': + base.order_id = first; + base.fill_atoms = second.atoms; + base.destination = payload[2]; + break; + case 'ConcludeOrder': + base.order_id = first; + base.destination = second; + break; + default: return { - input: { - index, - input_type: 'UTXO', - source_id: input.Utxo.id.Transaction, - source_type: 'Transaction', - }, + input: { ...base, command: tag, decoded_payload: payload }, + unsupported: true, }; - } + } + return { input: base }; + } - if (input.Account) { - return { - input: { - index, - input_type: 'ACCOUNT', - // TODO: map account input fields if needed - }, - }; - } + if (input.Account) { + const { nonce, account } = input.Account; + if (account.DelegationBalance) { + const [delegation_id, amount] = account.DelegationBalance; + return { + input: { + input_type: 'Account', + account_type: 'DelegationBalance', + delegation_id, + amount: decodedCoinAmount(amount.atoms), + nonce: Number(nonce), + }, + }; + } + return { input: { input_type: 'Account', decoded_account: account }, unsupported: true }; + } - throw new Error(`Unsupported input type at index ${index}`); - }), + return { input: { index, decoded: input }, unsupported: true }; +}; - outputs: tx.outputs.map((output: any) => { - if (output.Transfer) { - const [value, destination] = output.Transfer; +const decodedOutput = (output: any): any => { + const tag = Object.keys(output)[0]; - return { - type: 'Transfer', - destination, - value: { - type: Object.keys(value)[0], - amount: { - atoms: value[Object.keys(value)[0]].atoms, - decimal: atomsToDecimal(value[Object.keys(value)[0]].atoms, 11), - }, - }, - }; + switch (tag) { + case 'Transfer': { + const [value, destination] = output.Transfer; + return { type: 'Transfer', destination, value: decodedValue(value) }; + } + case 'LockThenTransfer': { + const [value, destination, lock] = output.LockThenTransfer; + return { + type: 'LockThenTransfer', + destination, + value: decodedValue(value), + lock: { + type: lock.type, + content: lock.type === 'UntilTime' ? { timestamp: String(lock.content.timestamp) } : String(lock.content), + }, + }; + } + case 'Burn': { + return { type: 'BurnToken', value: decodedValue(output.Burn) }; + } + case 'DataDeposit': { + return { type: 'DataDeposit', data: new TextDecoder().decode(new Uint8Array(output.DataDeposit)) }; + } + case 'IssueFungibleToken': { + const v1 = output.IssueFungibleToken.V1; + let total_supply: any; + if (typeof v1.total_supply === 'string') { + total_supply = { type: v1.total_supply }; + } else if (v1.total_supply && typeof v1.total_supply === 'object') { + const [supplyType, supplyData] = Object.entries(v1.total_supply)[0] as [string, any]; + total_supply = + supplyType === 'Fixed' + ? { + type: 'Fixed', + amount: { atoms: supplyData.atoms, decimal: atomsToDecimal(supplyData.atoms, v1.number_of_decimals) }, + } + : { type: supplyType }; } + return { + type: 'IssueFungibleToken', + authority: v1.authority, + is_freezable: v1.is_freezable === 'Yes', + metadata_uri: decodedBytesField(v1.metadata_uri), + number_of_decimals: v1.number_of_decimals, + token_ticker: decodedBytesField(v1.token_ticker), + total_supply, + }; + } + case 'IssueNft': { + const [token_id, v0wrapper] = output.IssueNft; + const metadata = v0wrapper.V0.metadata; + return { + type: 'IssueNft', + token_id, + destination: null, + data: { + creator: metadata.creator ?? null, + name: decodedBytesField(metadata.name), + ticker: decodedBytesField(metadata.ticker), + description: decodedBytesField(metadata.description), + media_hash: decodedBytesField(metadata.media_hash), + media_uri: decodedBytesField(metadata.media_uri), + icon_uri: decodedBytesField(metadata.icon_uri), + additional_metadata_uri: decodedBytesField(metadata.additional_metadata_uri), + }, + }; + } + case 'CreateDelegationId': { + const [destination, pool_id] = output.CreateDelegationId; + return { type: 'CreateDelegationId', destination, pool_id }; + } + case 'DelegateStaking': { + const [amount, delegation_id] = output.DelegateStaking; + return { type: 'DelegateStaking', delegation_id, amount: decodedCoinAmount(amount.atoms) }; + } + case 'Htlc': { + const [value, htlc] = output.Htlc; + return { + type: 'Htlc', + value: decodedValue(value), + htlc: { + spend_key: htlc.spend_key, + refund_key: htlc.refund_key, + secret_hash: { hex: htlc.secret_hash, string: null }, + refund_timelock: { + type: htlc.refund_timelock.type, + content: + htlc.refund_timelock.type === 'UntilTime' + ? { timestamp: String(htlc.refund_timelock.content.timestamp) } + : String(htlc.refund_timelock.content), + }, + }, + }; + } + case 'CreateOrder': { + const { conclude_key, ask, give } = output.CreateOrder; + return { + type: 'CreateOrder', + conclude_destination: conclude_key, + ask_currency: ask.Coin ? { type: 'Coin' } : { type: 'TokenV1', token_id: ask.TokenV1[0] }, + ask_balance: ask.Coin ? decodedCoinAmount(ask.Coin.atoms) : decodedTokenAmount(ask.TokenV1[1].atoms), + give_currency: give.Coin ? { type: 'Coin' } : { type: 'TokenV1', token_id: give.TokenV1[0] }, + give_balance: give.Coin ? decodedCoinAmount(give.Coin.atoms) : decodedTokenAmount(give.TokenV1[1].atoms), + }; + } + default: + // Lossless passthrough instead of throwing: an unknown/unsupported + // output must not crash a display path (wallet sign-transaction screen). + return { type: 'Unsupported', wasm_tag: tag, decoded: output }; + } +}; - throw new Error(`Unsupported output type: ${Object.keys(output)[0]}`); - }), +const decoded_to_json_representation = (decoded: any) => { + const tx = decoded.V1; + + return { + inputs: tx.inputs.map((input: any, index: number) => decodedInput(input, index)), + outputs: tx.outputs.map((output: any) => decodedOutput(output)), }; }; diff --git a/packages/sdk/src/types/transaction.ts b/packages/sdk/src/types/transaction.ts index 48564fc..bc3f7f3 100644 --- a/packages/sdk/src/types/transaction.ts +++ b/packages/sdk/src/types/transaction.ts @@ -19,7 +19,7 @@ type LockThenTransferUtxo = BaseUtxo & { type: 'LockThenTransfer'; lock: { type: 'ForBlockCount' | 'UntilTime'; - content: string; + content: string | number | { timestamp: string }; }; }; @@ -40,7 +40,7 @@ type IssueNftUtxo = { }; }; -type Utxo = TransferUtxo | LockThenTransferUtxo | IssueNftUtxo | HtlcUtxo; +export type Utxo = TransferUtxo | LockThenTransferUtxo | IssueNftUtxo | HtlcUtxo; export type UtxoInput = { input: { @@ -136,7 +136,7 @@ type ConcludeOrderInput = { utxo: null; }; -type Input = +export type Input = | UtxoInput | MintTokensInput | UnmintTokensInput @@ -149,29 +149,29 @@ type Input = | ConcludeOrderInput | DelegationWithdrawInput; -type TransferOutput = { +export type TransferOutput = { type: 'Transfer'; destination: string; value: Value; }; -type LockThenTransferOutput = { +export type LockThenTransferOutput = { type: 'LockThenTransfer'; destination: string; value: Value; lock: { type: 'ForBlockCount' | 'UntilTime'; - content: string; + content: string | number | { timestamp: string }; }; }; -type CreateDelegationIdOutput = { +export type CreateDelegationIdOutput = { type: 'CreateDelegationId'; destination: string; pool_id: string; }; -type DelegateStakingOutput = { +export type DelegateStakingOutput = { type: 'DelegateStaking'; delegation_id: string; amount: AmountFields; @@ -187,26 +187,26 @@ type DelegationWithdrawInput = { }; }; -type BurnTokenOutput = { +export type BurnTokenOutput = { type: 'BurnToken'; value: Value; }; -type DataDepositOutput = { +export type DataDepositOutput = { type: 'DataDeposit'; data: string; }; -type TotalSupplyValue = +export type TotalSupplyValue = | { - type: 'Unlimited' | 'Lockable'; -} + type: 'Unlimited' | 'Lockable'; + } | { - type: 'Fixed'; - amount: AmountFields; -}; + type: 'Fixed'; + amount: AmountFields; + }; -type IssueFungibleTokenOutput = { +export type IssueFungibleTokenOutput = { type: 'IssueFungibleToken'; authority: string; is_freezable: boolean; @@ -218,36 +218,36 @@ type IssueFungibleTokenOutput = { export type Timelock = | { - type: 'UntilTime'; - content: { - timestamp: string; - }; -} + type: 'UntilTime'; + content: { + timestamp: string | number; + }; + } | { - type: 'ForBlockCount'; - content: number; -}; + type: 'ForBlockCount'; + content: string | number; + }; -type HtlcOutput = { +export type HtlcOutput = { type: 'Htlc'; value: { token_id?: string; type: 'Coin' | 'TokenV1'; amount: AmountFields; - } + }; token_id?: string; htlc: { refund_key: string; secret_hash: { hex: string; string: string | null; - }, + }; spend_key: string; refund_timelock: Timelock; - } -} + }; +}; -type IssueNftOutput = { +export type IssueNftOutput = { type: 'IssueNft'; destination: string; token_id?: string; @@ -274,7 +274,7 @@ type CreateOrderOutput = { conclude_destination: string; }; -type Output = +export type Output = | TransferOutput | LockThenTransferOutput | BurnTokenOutput @@ -286,23 +286,23 @@ type Output = | DelegateStakingOutput | HtlcOutput; -type AmountFields = { +export type AmountFields = { atoms: string; decimal: string; }; -type Coin = { +export type Coin = { type: 'Coin'; amount: AmountFields; }; -type Token = { +export type Token = { type: 'TokenV1'; token_id: string; amount: AmountFields; }; -type Value = Coin | Token; +export type Value = Coin | Token; export interface TransactionJSONRepresentation { inputs: Input[]; @@ -311,13 +311,7 @@ export interface TransactionJSONRepresentation { id: string; } - -interface Outpoint { - id: string; - index: number; -} - -interface UtxoOutpoint { +export interface UtxoOutpoint { index: number; source_type: SourceId; source_id: string; diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index ee72b83..f31af9c 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -20,7 +20,7 @@ function stringToUint8Array(str: string): Uint8Array { function hexToUint8Array(hex: any) { if (hex.length % 2 !== 0) { - throw new Error("Invalid hex string"); + throw new Error('Invalid hex string'); } const array = new Uint8Array(hex.length / 2); @@ -33,7 +33,7 @@ function hexToUint8Array(hex: any) { function uint8ArrayToHex(bytes: Uint8Array): string { return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) + .map((b) => b.toString(16).padStart(2, '0')) .join(''); } @@ -48,7 +48,8 @@ function atomsToDecimal(atoms: string | number, decimals: number): string { if (atomsLength <= decimals) { // Pad with leading zeros const padded = atomsStr.padStart(decimals, '0'); - return '0.' + padded.replace(/0+$/, '') || '0'; + const fractionalPart = padded.replace(/0+$/, ''); + return fractionalPart === '' ? '0' : '0.' + fractionalPart; } // Insert decimal point @@ -58,11 +59,18 @@ function atomsToDecimal(atoms: string | number, decimals: number): string { return fractionalPart === '' ? integerPart : `${integerPart}.${fractionalPart}`; } -export { - mergeUint8Arrays, - stringToUint8Array, - hexToUint8Array, - uint8ArrayToHex, - BASE58_ALPHABET, - atomsToDecimal, +export function decimalsToAtoms(value: string | number, decimals: number): bigint { + const v = Number(value); + const [intPart, fracPart = ''] = v.toFixed(decimals).split('.'); + const paddedFrac = (fracPart + '0'.repeat(decimals)).slice(0, decimals); + const full = intPart + paddedFrac; + return BigInt(full); } + +export function decimals(value: string | number, decimals: number): string { + const v = Number(value); + if (isNaN(v)) return '0'; + return v.toFixed(decimals).replace(/\.?0+$/, ''); +} + +export { mergeUint8Arrays, stringToUint8Array, hexToUint8Array, uint8ArrayToHex, BASE58_ALPHABET, atomsToDecimal }; diff --git a/packages/sdk/src/wallet-state.ts b/packages/sdk/src/wallet-state.ts index 5a65df8..f002b5c 100644 --- a/packages/sdk/src/wallet-state.ts +++ b/packages/sdk/src/wallet-state.ts @@ -110,13 +110,7 @@ export type SpendPolicy = { * Derived status of a wallet-owned output. */ export type WalletUtxoStatus = - | 'confirmed' - | 'unconfirmed' - | 'spent' - | 'spent_pending' - | 'conflicted' - | 'orphaned' - | 'rejected'; + 'confirmed' | 'unconfirmed' | 'spent' | 'spent_pending' | 'conflicted' | 'orphaned' | 'rejected'; /** * Wallet-owned UTXO with derived state metadata. diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index d0b5f92..df635e6 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -1,50 +1,33 @@ import { Client } from '../src/mintlayer-connect-sdk' import fetchMock from 'jest-fetch-mock'; -import { addresses } from './__mocks__/accounts/account_01'; +import { setupApiMocks } from './helpers/api-mocks'; + +let mocks: ReturnType; beforeEach(() => { - fetchMock.resetMocks(); - - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue({ - addressesByChain: { - mintlayer: { - receiving: ['taddr1receiving'], - change: ['taddr1change'], - }, - }, - }), - restore: jest.fn().mockResolvedValue({ - addressesByChain: { - mintlayer: { - receiving: ['taddr1receiving'], - change: ['taddr1change'], - }, + mocks = setupApiMocks(); + + // this suite uses a minimal wallet: one receiving / one change address + const walletAddresses = { + addressesByChain: { + mintlayer: { + receiving: ['taddr1receiving'], + change: ['taddr1change'], }, - }), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), + }, }; - - fetchMock.doMock(); + (window as any).mojito.connect = jest.fn().mockResolvedValue(walletAddresses); + (window as any).mojito.restore = jest.fn().mockResolvedValue(walletAddresses); fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/address/')) { + if (req.url.includes('/address/')) { return JSON.stringify({ coin_balance: { atoms: '1000000000000', decimal: '10' }, tokens: [], }); } - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + return mocks.defaultRouter(req); }); }); diff --git a/packages/sdk/tests/helpers/api-mocks.ts b/packages/sdk/tests/helpers/api-mocks.ts new file mode 100644 index 0000000..3ca77af --- /dev/null +++ b/packages/sdk/tests/helpers/api-mocks.ts @@ -0,0 +1,170 @@ +import fetchMock, { MockResponseInitFunction } from 'jest-fetch-mock'; + +import { Client } from '../../src/mintlayer-connect-sdk'; + +import { addresses, utxos } from '../__mocks__/accounts/account_01'; + +export const MOCK_TOKEN_ID = 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq'; + +/** + * Id of the secondary shared token fixture — identical to `MOCK_TOKEN` but + * with 11 decimals. Several suites mock both decimals variants. + */ +export const MOCK_TOKEN_11_DECIMALS_ID = 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l'; +export const MOCK_TOKEN_AUTHORITY = 'tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx'; + +/** + * Shared token details fixture: authority-owned token with 8 decimals and + * `next_nonce` 7 (so auto nonces start at 7 and explicit-nonce validation has + * a known baseline). + */ +export const MOCK_TOKEN = { + authority: MOCK_TOKEN_AUTHORITY, + circulating_supply: { + atoms: '209000000000', + decimal: '2090', + }, + frozen: false, + is_locked: false, + is_token_freezable: true, + is_token_unfreezable: null, + metadata_uri: { + hex: '697066733a2f2f516d4578616d706c6548617368313233', + string: 'ipfs://QmExampleHash123', + }, + next_nonce: 7, + number_of_decimals: 8, + token_ticker: { + hex: '58595a32', + string: 'XYZ2', + }, + total_supply: { + Fixed: { + atoms: '100000000000000', + }, + }, +}; + +/** `MOCK_TOKEN` with 11 decimals (key order preserved for snapshot parity). */ +export const MOCK_TOKEN_11_DECIMALS = { ...MOCK_TOKEN, number_of_decimals: 11 }; + +export interface ApiMockOptions { + /** Height served by `GET /chain/tip` (default 200000). */ + chainTipHeight?: number; + /** Token details served per token id by `GET /token/`; defaults to `{ [MOCK_TOKEN_ID]: MOCK_TOKEN }`. */ + tokens?: Record; + /** UTXOs served by the `/batch` endpoint; defaults to the account_01 fixtures. */ + utxos?: any[]; + /** Addresses resolved by the `window.mojito` stubs (defaults to account_01). */ + addresses?: any; + /** Value resolved by `window.mojito.request` (default 'signed-transaction'). */ + signedResponse?: any; +} + +export interface ApiMocks { + /** The `window.mojito` stub installed for the test — assert on its jest.fn()s. */ + mojito: { + isExtension: boolean; + connect: jest.Mock; + restore: jest.Mock; + disconnect: jest.Mock; + request: jest.Mock; + }; + addresses: any; + utxos: any[]; + tokens: Record; + /** + * The shared routing rules (chain tip, token details, UTXO batch). Suites + * with extra endpoints register their own `fetchMock.mockResponse` router + * (the last registration wins) and delegate all unmatched URLs to this + * function. + */ + defaultRouter: MockResponseInitFunction; +} + +/** The shared fetch routing rules shared by transaction-building suites. */ +function createDefaultRouter(options: { + chainTipHeight: number; + tokens: Record; + utxoList: any[]; +}): MockResponseInitFunction { + return async req => { + const url = req.url; + + if (url.endsWith('/chain/tip')) { + return JSON.stringify({ height: options.chainTipHeight }); + } + + if (url.includes('/token/')) { + const tokenId = url.split('/token/').pop()!; + const details = options.tokens[tokenId]; + if (details) { + return JSON.stringify(details); + } + return JSON.stringify({ a: 'b' }); + } + + if (url.endsWith('/batch')) { + return { + body: JSON.stringify({ + results: [options.utxoList], + }), + }; + } + + console.warn('No mock for:', url); + return JSON.stringify({ error: 'No mock defined' }); + }; +} + +/** + * Installs the shared API test double used by transaction-building suites: + * a `fetch` router (chain tip, token details, UTXO batch) plus a + * `window.mojito` wallet stub. Call it from `beforeEach` for a deterministic, + * network-free setup; the returned object exposes the stubs for assertions. + * + * Note: `fetchMock.mockResponse` replaces the previously registered router, + * so a suite needing extra endpoints must register its own router AFTER this + * call and delegate unmatched URLs to the returned `defaultRouter`. + */ +export function setupApiMocks(options: ApiMockOptions = {}): ApiMocks { + const { + chainTipHeight = 200000, + tokens = { [MOCK_TOKEN_ID]: MOCK_TOKEN }, + utxos: utxoList = utxos, + addresses: addressList = addresses, + signedResponse = 'signed-transaction', + } = options; + + const mojito: ApiMocks['mojito'] = { + isExtension: true, + connect: jest.fn().mockResolvedValue(addressList), + restore: jest.fn().mockResolvedValue(addressList), + disconnect: jest.fn().mockResolvedValue(undefined), + request: jest.fn().mockResolvedValue(signedResponse), + }; + (window as any).mojito = mojito; + + fetchMock.resetMocks(); + fetchMock.doMock(); + + const defaultRouter = createDefaultRouter({ chainTipHeight, tokens, utxoList }); + + fetchMock.mockResponse(defaultRouter); + + return { mojito, addresses: addressList, utxos: utxoList, tokens, defaultRouter }; +} + +/** A client with `network: 'testnet'`, `autoRestore: false`, already connected. */ +export async function createConnectedClient(): Promise { + const client = await Client.create({ network: 'testnet', autoRestore: false }); + await client.connect(); + return client; +} + +/** Asserts `p` rejects with `expected` and that no fetch went out to the provider. */ +export async function expectRejectionWithoutProviderCalls(p: Promise, expected: string) { + const callsBefore = fetchMock.mock.calls.length; + await expect(p).rejects.toThrow(expected); + expect(fetchMock.mock.calls).toHaveLength(callsBefore); +} diff --git a/packages/sdk/tests/htlc.test.ts b/packages/sdk/tests/htlc.test.ts index 1cbec91..cb98dac 100644 --- a/packages/sdk/tests/htlc.test.ts +++ b/packages/sdk/tests/htlc.test.ts @@ -2,29 +2,61 @@ import { Client } from '../src/mintlayer-connect-sdk'; import fetchMock from 'jest-fetch-mock'; import { createHash } from 'crypto' -import { addresses, utxos } from './__mocks__/accounts/account_02' +import { utxos } from './__mocks__/accounts/account_02' +import { + createConnectedClient, + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + expectRejectionWithoutProviderCalls, + setupApiMocks, +} from './helpers/api-mocks'; -beforeEach(() => { - fetchMock.resetMocks(); +const HUG_TOKEN_ID = 'tmltk18wg2xa7qxflwmcjpcd7nepsrsjj0gcrqyc7k5ej4cq5q3lf7ry7qtm2l6z'; - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; +const HUG_TOKEN = { + authority: 'tmt1q9874wgx6enm2mzfu0yxhzleu84pp00l95l7er5z', + circulating_supply: { + atoms: '1000000000000000', + decimal: '10000', + }, + frozen: false, + is_locked: false, + is_token_freezable: false, + is_token_unfreezable: null, + metadata_uri: { + hex: '697066733a2f2f6261667962656965706a746a34653271736b7a7561366763777173767267633362377164363777326d757132687a716333766f67656c666b7377652f746f6b656e5f6d657461646174612e6a736f6e', + string: 'ipfs://bafybeiepjtj4e2qskzua6gcwqsvrgc3b7qd67w2muq2hzqc3vogelfkswe/token_metadata.json', + }, + next_nonce: 1, + number_of_decimals: 11, + token_ticker: { + hex: '485547', + string: 'HUG', + }, + total_supply: { + Fixed: { + atoms: '100000000000000000', + }, + }, +}; - fetchMock.doMock(); +let mocks: ReturnType; + +beforeEach(() => { + mocks = setupApiMocks({ + utxos, + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + [HUG_TOKEN_ID]: HUG_TOKEN, + }, + }); fetchMock.mockResponse(async req => { const url = req.url; - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - if (url.includes('/transaction/')) { const txId = url.split('/transaction/').pop(); if(txId === '513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207') { @@ -190,105 +222,7 @@ beforeEach(() => { } } - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk18wg2xa7qxflwmcjpcd7nepsrsjj0gcrqyc7k5ej4cq5q3lf7ry7qtm2l6z') { - return JSON.stringify({ - "authority": "tmt1q9874wgx6enm2mzfu0yxhzleu84pp00l95l7er5z", - "circulating_supply": { - "atoms": "1000000000000000", - "decimal": "10000" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": false, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f6261667962656965706a746a34653271736b7a7561366763777173767267633362377164363777326d757132687a716333766f67656c666b7377652f746f6b656e5f6d657461646174612e6a736f6e", - "string": "ipfs://bafybeiepjtj4e2qskzua6gcwqsvrgc3b7qd67w2muq2hzqc3vogelfkswe/token_metadata.json" - }, - "next_nonce": 1, - "number_of_decimals": 11, - "token_ticker": { - "hex": "485547", - "string": "HUG" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + return mocks.defaultRouter(req); }); }); @@ -392,3 +326,15 @@ test('extract Htlc from transaction', async () => { expect(Array.from(secret)).toEqual(Array.from(secret_original)); }); + +test('rejects a malformed transaction id before any provider call', async () => { + const client = await createConnectedClient(); + + await expectRejectionWithoutProviderCalls( + client.extractHtlcSecret({ + transaction_id: 'not-a-txid', + transaction_hex: '00', + }), + 'transaction_id must be a 64-character hex string', + ); +}); diff --git a/packages/sdk/tests/issue-token.test.ts b/packages/sdk/tests/issue-token.test.ts index 886d5db..869ce02 100644 --- a/packages/sdk/tests/issue-token.test.ts +++ b/packages/sdk/tests/issue-token.test.ts @@ -1,100 +1,19 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; beforeEach(() => { - fetchMock.resetMocks(); - - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, }); }); diff --git a/packages/sdk/tests/orders.test.ts b/packages/sdk/tests/orders.test.ts index 35ced17..dc8e62f 100644 --- a/packages/sdk/tests/orders.test.ts +++ b/packages/sdk/tests/orders.test.ts @@ -1,118 +1,56 @@ import { Client } from '../src/mintlayer-connect-sdk'; import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; + +const JUST_TOKEN_ID = 'tmltk16yw5sdh3x3ryxu83w50jzs462ttp4au0j2tff6vztt0nyfve892q7ggcsg'; + +const JUST_TOKEN = { + authority: 'tmt1q9w0srpalrz5t3ep54tjq774cf09p6683sqtqsxl', + circulating_supply: { + atoms: '10000000000', + decimal: '10000', + }, + frozen: false, + is_locked: false, + is_token_freezable: true, + is_token_unfreezable: null, + metadata_uri: { + hex: '697066733a2f2f6261666b726569636f6166727062627077643265353235677a6d776972706a796a6c6961626e776b61766832627a65746e6f7373666377767534', + string: 'ipfs://bafkreicoafrpbbpwd2e525gzmwirpjyjliabnwkavh2bzetnoossfcwvu4', + }, + next_nonce: 1, + number_of_decimals: 6, + token_ticker: { + hex: '4a757374546f6b656e', + string: 'JustToken', + }, + total_supply: 'Unlimited', +}; let spy: jest.SpyInstance; +let mocks: ReturnType; beforeEach(() => { - fetchMock.resetMocks(); - spy = jest.spyOn(Client.prototype as any, 'buildTransaction'); - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); + mocks = setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + [JUST_TOKEN_ID]: JUST_TOKEN, + }, + }); fetchMock.mockResponse(async req => { const url = req.url; - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk16yw5sdh3x3ryxu83w50jzs462ttp4au0j2tff6vztt0nyfve892q7ggcsg') { - return JSON.stringify({ - "authority": "tmt1q9w0srpalrz5t3ep54tjq774cf09p6683sqtqsxl", - "circulating_supply": { - "atoms": "10000000000", - "decimal": "10000" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f6261666b726569636f6166727062627077643265353235677a6d776972706a796a6c6961626e776b61766832627a65746e6f6f7373666377767534", - "string": "ipfs://bafkreicoafrpbbpwd2e525gzmwirpjyjliabnwkavh2bzetnoossfcwvu4" - }, - "next_nonce": 1, - "number_of_decimals": 6, - "token_ticker": { - "hex": "4a757374546f6b656e", - "string": "JustToken" - }, - "total_supply": "Unlimited" - }); - } - return JSON.stringify({ a: 'b' }); - } - if(url.includes('/order/')) { const orderId = req.url.split('/order/').pop(); @@ -183,16 +121,7 @@ beforeEach(() => { } } - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + return mocks.defaultRouter(req); }); }); diff --git a/packages/sdk/tests/raw-transaction.test.ts b/packages/sdk/tests/raw-transaction.test.ts new file mode 100644 index 0000000..aef5022 --- /dev/null +++ b/packages/sdk/tests/raw-transaction.test.ts @@ -0,0 +1,906 @@ +// Low-level wasm primitives come from the wasm lib directly — the SDK no +// longer re-exports them. +import { + Amount, + Network, + SourceId, + data_deposit_fee, + encode_input_for_utxo, + encode_outpoint_source_id, + encode_output_transfer, + encode_transaction, + estimate_transaction_size, + fungible_token_issuance_fee, + nft_issuance_fee, + token_supply_change_fee, +} from '@mintlayer/wasm-lib'; +import { BuiltTransaction, Client, FEE_BLOCK_HEIGHT } from '../src/mintlayer-connect-sdk'; +import { + createConnectedClient, + expectRejectionWithoutProviderCalls, + MOCK_TOKEN, + MOCK_TOKEN_AUTHORITY, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; + +import { addresses } from './__mocks__/accounts/account_01'; + +const receiving = addresses.addressesByChain.mintlayer.receiving; +const changeAddresses = addresses.addressesByChain.mintlayer.change; + +const USER_ADDRESS = receiving[0]; // tmt1q9874wgx6enm2mzfu0yxhzleu84pp00l95l7er5z +const FEE_ADDRESS = receiving[1]; // tmt1q93ldqwvlpq5xc0n2nqnvgdzghqf6krjxsfryl0c +const TOKEN_AUTHORITY = MOCK_TOKEN_AUTHORITY; +const TOKEN_ID = MOCK_TOKEN_ID; +/** Decimals of the mocked token fixture (8). */ +const TOKEN_DECIMALS: number = MOCK_TOKEN.number_of_decimals; +const TOKEN_ATOMS = (tokens: bigint) => (tokens * BigInt(10 ** TOKEN_DECIMALS)).toString(); + +/** Largest coin UTXO of the mocked account (17032.056043 ML). */ +const LARGEST_COIN_UTXO = { + source_id: 'af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab', + index: 1, + atoms: '1703205604300000', +}; + +/** + * Assertion view of the public build result. Derived from the exported + * `BuiltTransaction` (no shape duplicated here) with the `inputs`/`outputs` + * unions loosened to `any[]` and `fee` narrowed — the tests index into + * discriminated fields of specific union members directly. + */ +type LooseBuiltTransaction = Omit & { + JSONRepresentation: Omit & { + inputs: any[]; + outputs: any[]; + fee: { atoms: string; decimal: string }; + }; +}; + +/** Sum of the Coin value carried by UTXO inputs of a transaction (token inputs excluded). */ +function coinInputSum(tx: BuiltTransaction): bigint { + return tx.JSONRepresentation.inputs + .filter((i: any) => i.input.input_type === 'UTXO' && i.utxo.value.type === 'Coin') + .reduce((acc: bigint, i: any) => acc + BigInt(i.utxo.value.amount.atoms), 0n); +} + +function coinTransferSum(tx: BuiltTransaction): bigint { + return tx.JSONRepresentation.outputs + .filter((o: any) => o.type === 'Transfer' && o.value.type === 'Coin') + .reduce((acc: bigint, o: any) => acc + BigInt(o.value.amount.atoms), 0n); +} + +/** Sum of TokenV1 UTXO input values for a given token id. */ +function tokenInputSum(tx: BuiltTransaction, tokenId: string): bigint { + return tx.JSONRepresentation.inputs + .filter( + (i: any) => + i.input.input_type === 'UTXO' && + i.utxo.value.type === 'TokenV1' && + i.utxo.value.token_id === tokenId, + ) + .reduce((acc: bigint, i: any) => acc + BigInt(i.utxo.value.amount.atoms), 0n); +} + +/** Sum of token amounts in outputs matching `match` for a given token id. */ +function tokenOutputSum(tx: BuiltTransaction, tokenId: string, match: (o: any) => boolean = () => true): bigint { + return tx.JSONRepresentation.outputs + .filter((o: any) => o.value?.type === 'TokenV1' && o.value.token_id === tokenId && match(o)) + .reduce((acc: bigint, o: any) => acc + BigInt(o.value.amount.atoms), 0n); +} + +/** Shared minimal NFT metadata fixture (all fields within length caps). */ +const NFT_DATA = { + name: 'Name', + ticker: 'PPP', + description: 'Description', + media_hash: '0100060009', + media_uri: 'ipfs://media', + icon_uri: 'ipfs://icon', + additional_metadata_uri: 'ipfs://meta', +}; + +const DELEGATION_ID = 'tdelg1d57nmkp24k0rh0fgsjnjy78wxql8wvgr420ncdsesvssvdgfcg6sx6262w'; + +function coinTransferOutput(destination: string, atoms: string) { + return { + type: 'Transfer' as const, + destination, + value: { type: 'Coin' as const, amount: { atoms, decimal: (BigInt(atoms) / BigInt(1e11)).toString() } }, + }; +} + +let mocks: ReturnType; + +beforeEach(() => { + mocks = setupApiMocks(); +}); + +describe('buildRawTransaction', () => { + test('issuance + transfers + fee output in one transaction', async () => { + const client = await createConnectedClient(); + + const FEE_ATOMS = '1000000000000'; // 10 ML + const USER_ATOMS = '50000000000000'; // 500 ML + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: 'ipfs://QmExampleHash123', + number_of_decimals: 11, + token_ticker: 'XYZ2', + total_supply: { type: 'Fixed', amount: { atoms: '1000000000000', decimal: '10' } }, + }, + { + type: 'Transfer', + destination: FEE_ADDRESS, + value: { type: 'Coin', amount: { atoms: FEE_ATOMS, decimal: '10' } }, + }, + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: USER_ATOMS, decimal: '500' } }, + }, + ], + })) as LooseBuiltTransaction; + + const { JSONRepresentation } = tx; + + // 3 caller outputs + 1 change output + expect(JSONRepresentation.outputs).toHaveLength(4); + const change = JSONRepresentation.outputs[3]; + expect(change.type).toBe('Transfer'); + expect(change.destination).toBe(changeAddresses[0]); + expect(change.value.type).toBe('Coin'); + + // caller outputs preserved, order intact + expect(JSONRepresentation.outputs[0].type).toBe('IssueFungibleToken'); + expect(JSONRepresentation.outputs[1].destination).toBe(FEE_ADDRESS); + // fee output amount preserved exactly + expect(JSONRepresentation.outputs[1].value.amount.atoms).toBe(FEE_ATOMS); + expect(JSONRepresentation.outputs[1].value.amount.decimal).toBe('10'); + expect(JSONRepresentation.outputs[2].destination).toBe(USER_ADDRESS); + expect(JSONRepresentation.outputs[2].value.amount.atoms).toBe(USER_ATOMS); + + // issuance fee enters the accounting + const issuanceFee = BigInt(fungible_token_issuance_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + expect(issuanceFee).toBeGreaterThan(0n); + + // coin conservation: inputs = caller coin outputs + change + tx fee + issuance fee + const inputSum = coinInputSum(tx); + const explicitSum = BigInt(FEE_ATOMS) + BigInt(USER_ATOMS); + const fee = BigInt(JSONRepresentation.fee.atoms); + expect(inputSum).toBe(explicitSum + BigInt(change.value.amount.atoms) + fee + issuanceFee); + + // issuance fee is charged on top of the plain outputs+fee accounting + expect(inputSum).toBeGreaterThan(explicitSum + BigInt(change.value.amount.atoms)); + + // transaction identity + expect(tx.transaction_id).toMatch(/^[0-9a-f]{64}$/); + expect(JSONRepresentation.id).toBe(tx.transaction_id); + expect(tx.HEXRepresentation_unsigned).toMatch(/^[0-9a-f]+$/); + expect(tx.BINRepresentation.inputs.length).toBeGreaterThan(0); + expect(tx.BINRepresentation.outputs.length).toBe(JSONRepresentation.outputs.length); + }); + + test('multi transfer selects UTXOs automatically and returns change to the change address', async () => { + const client = await createConnectedClient(); + + const amounts = ['5000000000', '25000000000', '7000000000']; // 50 + 250 + 70 ML + + const tx = (await client.buildRawTransaction({ + outputs: amounts.map((atoms, i) => ({ + type: 'Transfer' as const, + destination: receiving[i], + value: { type: 'Coin' as const, amount: { atoms, decimal: (Number(atoms) / 1e11).toString() } }, + })), + })) as LooseBuiltTransaction; + + const outs = tx.JSONRepresentation.outputs; + expect(outs).toHaveLength(4); + + // caller outputs preserved in order + expect(outs.slice(0, 3).map((o: any) => o.destination)).toEqual([receiving[0], receiving[1], receiving[2]]); + expect(outs.slice(0, 3).map((o: any) => o.value.amount.atoms)).toEqual(amounts); + + // auto UTXO selection picked the largest coin UTXO of the mocked account + const coinInputs = tx.JSONRepresentation.inputs.filter((i: any) => i.input.input_type === 'UTXO'); + expect(coinInputs.length).toBeGreaterThanOrEqual(1); + expect(coinInputs[0].input.source_id).toBe(LARGEST_COIN_UTXO.source_id); + expect(coinInputs[0].input.index).toBe(LARGEST_COIN_UTXO.index); + + // change goes to the first change address + const change = outs[3]; + expect(change.type).toBe('Transfer'); + expect(change.destination).toBe(changeAddresses[0]); + expect(change.value.type).toBe('Coin'); + + // coin conservation: inputs = outputs + change + fee + const inputSum = coinInputSum(tx); + const outSum = amounts.reduce((acc, atoms) => acc + BigInt(atoms), 0n); + const fee = BigInt(tx.JSONRepresentation.fee.atoms); + expect(inputSum).toBe(outSum + BigInt(change.value.amount.atoms) + fee); + }); + + test('mint inputs without nonce get sequential auto nonces and inferred authority', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000000000', decimal: '10' } }, + }, + ], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: '100000000000', decimal: '1000' }, + }, + }, + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: '50000000000', decimal: '500' }, + }, + }, + ], + })) as LooseBuiltTransaction; + + const mints = tx.JSONRepresentation.inputs.filter((i: any) => i.input.command === 'MintTokens'); + expect(mints).toHaveLength(2); + + // token mock exposes next_nonce 7 → auto nonces [7, 8] in input order + expect(mints[0].input.nonce).toBe(7); + expect(mints[1].input.nonce).toBe(8); + expect(mints[0].input.token_id).toBe(TOKEN_ID); + expect(mints[1].input.token_id).toBe(TOKEN_ID); + + // authority auto-inferred from token details + expect(mints[0].input.authority).toBe(TOKEN_AUTHORITY); + expect(mints[1].input.authority).toBe(TOKEN_AUTHORITY); + + // coin conservation including per-mint token supply change fee + const supplyFee = BigInt(token_supply_change_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + const inputSum = coinInputSum(tx); + const fee = BigInt(tx.JSONRepresentation.fee.atoms); + expect(inputSum).toBe(coinTransferSum(tx) + fee + 2n * supplyFee); + }); + + test('explicit nonce overrides the auto lookup', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000000000', decimal: '10' } }, + }, + ], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + nonce: 42, + amount: { atoms: '100000000000', decimal: '1000' }, + }, + }, + ], + })) as LooseBuiltTransaction; + + const mints = tx.JSONRepresentation.inputs.filter((i: any) => i.input.command === 'MintTokens'); + expect(mints).toHaveLength(1); + expect(mints[0].input.nonce).toBe(42); + // authority still auto-inferred when omitted + expect(mints[0].input.authority).toBe(TOKEN_AUTHORITY); + }); +}); + +describe('buildRawTransaction rejections', () => { + test('rejects empty outputs array without provider calls', async () => { + const client = await createConnectedClient(); + + await expectRejectionWithoutProviderCalls( + client.buildRawTransaction({ outputs: [] }), + 'At least one output is required', + ); + }); + + test('rejects unknown output type without provider calls', async () => { + const client = await createConnectedClient(); + + await expectRejectionWithoutProviderCalls( + client.buildRawTransaction({ + outputs: [ + { type: 'NotAnOutput', destination: USER_ADDRESS } as any, + ], + }), + 'unknown output type', + ); + }); + + test('rejects malformed atoms without provider calls', async () => { + const client = await createConnectedClient(); + + for (const atoms of ['-100', '1.5', 'abc', '']) { + await expectRejectionWithoutProviderCalls( + client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: atoms as any, decimal: '1' } }, + }, + ], + }), + 'amount.atoms must be a non-negative integer', + ); + } + }); + + test('rejects UTXO-style input passed in inputs without provider calls', async () => { + const client = await createConnectedClient(); + + await expectRejectionWithoutProviderCalls( + client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000000000', decimal: '10' } }, + }, + ], + inputs: [ + { + input: { + input_type: 'UTXO', + index: 0, + source_id: 'ab'.repeat(32), + source_type: 'Transaction', + }, + } as any, + ], + }), + 'unsupported input_type', + ); + }); +}); + +describe('wasm re-exports', () => { + test('encode_output_transfer / encode_transaction / estimate_transaction_size are usable', () => { + expect(typeof encode_output_transfer).toBe('function'); + expect(typeof encode_transaction).toBe('function'); + expect(typeof estimate_transaction_size).toBe('function'); + + const amount = Amount.from_atoms('1000000000'); + const output = encode_output_transfer(amount, USER_ADDRESS, Network.Testnet); + expect(output).toBeInstanceOf(Uint8Array); + expect(output.length).toBeGreaterThan(0); + + const outpoint = encode_outpoint_source_id(new Uint8Array(32), SourceId.Transaction); + const input = encode_input_for_utxo(outpoint, 0); + expect(input).toBeInstanceOf(Uint8Array); + + const transaction = encode_transaction(input, output, BigInt(0)); + expect(transaction).toBeInstanceOf(Uint8Array); + expect(transaction.length).toBeGreaterThan(0); + + const size = estimate_transaction_size(input, [USER_ADDRESS], output, Network.Testnet); + expect(typeof size).toBe('number'); + expect(size).toBeGreaterThan(0); + }); +}); + +describe('RawStringField normalization', () => { + test('plain strings are auto-wrapped into hex/string pairs', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: 'ipfs://x', + number_of_decimals: 11, + token_ticker: 'XYZ', + total_supply: { type: 'Unlimited' }, + }, + ], + })) as LooseBuiltTransaction; + + const issuance = tx.JSONRepresentation.outputs[0]; + expect(issuance.type).toBe('IssueFungibleToken'); + expect(issuance.metadata_uri).toEqual({ hex: '697066733a2f2f78', string: 'ipfs://x' }); + expect(issuance.token_ticker).toEqual({ hex: '58595a', string: 'XYZ' }); + }); +}); + +describe('buildRawTransaction extended coverage', () => { + test('TokenV1 transfer + burnToken select token UTXOs, return token change and respect decimals', async () => { + const client = await createConnectedClient(); + + // 10 tokens each at the fixture's 8 decimals + const TRANSFER_ATOMS = TOKEN_ATOMS(10n); + const BURN_ATOMS = TOKEN_ATOMS(10n); + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'TokenV1', token_id: TOKEN_ID, amount: { atoms: TRANSFER_ATOMS, decimal: '10' } }, + }, + { + type: 'BurnToken', + value: { type: 'TokenV1', token_id: TOKEN_ID, amount: { atoms: BURN_ATOMS, decimal: '10' } }, + }, + ], + })) as LooseBuiltTransaction; + + // exactly one token UTXO selected: the largest one (1000 tokens) + const tokenInputs = tx.JSONRepresentation.inputs.filter( + (i: any) => i.input.input_type === 'UTXO' && i.utxo.value.type === 'TokenV1', + ); + expect(tokenInputs).toHaveLength(1); + expect(tokenInputs[0].input.source_id).toBe('cdcee4a44823978cc50245eff518a562c4461e74fee2ce2f46cade822f657e0d'); + expect(tokenInputs[0].input.index).toBe(0); + + // caller outputs preserved with decimals recomputed at 8 (atoms / 1e8 = 10) + const outs = tx.JSONRepresentation.outputs; + const transfer = outs.find((o: any) => o.type === 'Transfer' && o.value.type === 'TokenV1' && o.destination === USER_ADDRESS); + const burn = outs.find((o: any) => o.type === 'BurnToken'); + expect(transfer.value.amount).toEqual({ atoms: TRANSFER_ATOMS, decimal: '10' }); + expect(burn.value.amount).toEqual({ atoms: BURN_ATOMS, decimal: '10' }); + expect(burn.value.token_id).toBe(TOKEN_ID); + + // token change to the change address when overfunded: 1000 - 10 - 10 = 980 + const tokenChange = outs.find( + (o: any) => o.type === 'Transfer' && o.value.type === 'TokenV1' && o.destination === changeAddresses[0], + ); + expect(tokenChange).toBeDefined(); + expect(tokenChange.value.amount).toEqual({ atoms: TOKEN_ATOMS(980n), decimal: '980' }); + + // token conservation: inputs = transfer + burn + change + expect(tokenInputSum(tx, TOKEN_ID)).toBe( + tokenOutputSum(tx, TOKEN_ID), + ); + + // coin side: no coin outputs → coin inputs only cover the tx fee (+ coin change) + expect(coinInputSum(tx)).toBe(coinTransferSum(tx) + BigInt(tx.JSONRepresentation.fee.atoms)); + }); + + test('IssueFungibleToken + IssueNft in one tx pre-encodes the IssueNft token id and backfills the JSON', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: 'ipfs://QmExampleHash123', + number_of_decimals: 11, + token_ticker: 'XYZ2', + total_supply: { type: 'Unlimited' }, + }, + { + type: 'IssueNft', + destination: USER_ADDRESS, + data: NFT_DATA, + }, + ], + })) as LooseBuiltTransaction; + + const nft = tx.JSONRepresentation.outputs.find((o: any) => o.type === 'IssueNft'); + expect(nft).toBeDefined(); + + // token id derived from the inputs and backfilled into the JSON before encoding + expect(nft.token_id).not.toBe(''); + expect(nft.token_id).toMatch(/^[a-z0-9]{10,100}$/); + + // the wasm encoder accepted the pre-encoded id: transaction fully built + expect(tx.HEXRepresentation_unsigned).toMatch(/^[0-9a-f]+$/); + expect(tx.transaction_id).toMatch(/^[0-9a-f]{64}$/); + expect(tx.BINRepresentation.outputs).toHaveLength(tx.JSONRepresentation.outputs.length); + + // both issuance protocol fees enter the accounting + const nftFee = BigInt(nft_issuance_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + const ftaFee = BigInt(fungible_token_issuance_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + expect(coinInputSum(tx)).toBe( + coinTransferSum(tx) + BigInt(tx.JSONRepresentation.fee.atoms) + nftFee + ftaFee, + ); + }); + + test('two IssueNft outputs in one tx are rejected with a clear message', async () => { + const client = await createConnectedClient(); + + await expect( + client.buildRawTransaction({ + outputs: [ + { type: 'IssueNft', destination: USER_ADDRESS, data: NFT_DATA }, + { type: 'IssueNft', destination: FEE_ADDRESS, data: NFT_DATA }, + ], + }), + ).rejects.toThrow('Only one IssueNft output per transaction is supported'); + }); + + test('DataDeposit includes data_deposit_fee in the coin requirement', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [{ type: 'DataDeposit', data: 'hello-raw' }], + })) as LooseBuiltTransaction; + + // output preserved + expect(tx.JSONRepresentation.outputs[0]).toMatchObject({ type: 'DataDeposit', data: 'hello-raw' }); + + const depositFee = BigInt(data_deposit_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + expect(depositFee).toBeGreaterThan(0n); + + // coin conservation: inputs = change + tx fee + data deposit fee + expect(coinInputSum(tx)).toBe( + coinTransferSum(tx) + BigInt(tx.JSONRepresentation.fee.atoms) + depositFee, + ); + }); + + test('DelegateStaking counts the stake amount in the coin requirement', async () => { + const client = await createConnectedClient(); + + const STAKE_ATOMS = '10000000000000'; // 100 ML + + const tx = (await client.buildRawTransaction({ + outputs: [ + { type: 'DelegateStaking', delegation_id: DELEGATION_ID, amount: { atoms: STAKE_ATOMS, decimal: '100' } }, + ], + })) as LooseBuiltTransaction; + + const stake = tx.JSONRepresentation.outputs.find((o: any) => o.type === 'DelegateStaking'); + expect(stake).toMatchObject({ delegation_id: DELEGATION_ID, amount: { atoms: STAKE_ATOMS, decimal: '100' } }); + + // coin conservation: inputs = stake + change + tx fee (no protocol fee for staking) + expect(coinInputSum(tx)).toBe( + BigInt(STAKE_ATOMS) + coinTransferSum(tx) + BigInt(tx.JSONRepresentation.fee.atoms), + ); + }); + + test('CreateOrder counts the give side in the coin and token requirements', async () => { + const client = await createConnectedClient(); + + // (a) coin give side → coin requirement + const GIVE_ATOMS = '100000000000000'; // 1000 ML + const ASK_ATOMS = TOKEN_ATOMS(5n); // 5 tokens + + const txCoinGive = (await client.buildRawTransaction({ + outputs: [ + { + type: 'CreateOrder', + conclude_destination: receiving[2], + ask_currency: { type: 'TokenV1', token_id: TOKEN_ID }, + ask_balance: { atoms: ASK_ATOMS, decimal: '5' }, + give_currency: { type: 'Coin' }, + give_balance: { atoms: GIVE_ATOMS, decimal: '1000' }, + initially_asked: { atoms: ASK_ATOMS, decimal: '5' }, + initially_given: { atoms: GIVE_ATOMS, decimal: '1000' }, + }, + ], + })) as LooseBuiltTransaction; + + const order = txCoinGive.JSONRepresentation.outputs.find((o: any) => o.type === 'CreateOrder'); + expect(order.ask_currency).toEqual({ type: 'TokenV1', token_id: TOKEN_ID }); + expect(order.give_currency).toEqual({ type: 'Coin' }); + expect(order.give_balance).toEqual({ atoms: GIVE_ATOMS, decimal: '1000' }); + + // give side counted: inputs = give + change + fee; ask side needs no token inputs + expect(coinInputSum(txCoinGive)).toBe( + BigInt(GIVE_ATOMS) + coinTransferSum(txCoinGive) + BigInt(txCoinGive.JSONRepresentation.fee.atoms), + ); + const tokenInputs = txCoinGive.JSONRepresentation.inputs.filter( + (i: any) => i.input.input_type === 'UTXO' && i.utxo.value.type === 'TokenV1', + ); + expect(tokenInputs).toHaveLength(0); + + // (b) token give side → token requirement + const GIVE_TOKEN_ATOMS = TOKEN_ATOMS(10n); + + const txTokenGive = (await client.buildRawTransaction({ + outputs: [ + { + type: 'CreateOrder', + conclude_destination: receiving[2], + ask_currency: { type: 'Coin' }, + ask_balance: { atoms: GIVE_ATOMS, decimal: '1000' }, + give_currency: { type: 'TokenV1', token_id: TOKEN_ID }, + give_balance: { atoms: GIVE_TOKEN_ATOMS, decimal: '10' }, + initially_asked: { atoms: GIVE_ATOMS, decimal: '1000' }, + initially_given: { atoms: GIVE_TOKEN_ATOMS, decimal: '10' }, + }, + ], + })) as LooseBuiltTransaction; + + // token UTXOs selected for the give side; token conservation holds + // (inputs = give_balance + token change; CreateOrder carries amounts outside `value`) + expect(tokenInputSum(txTokenGive, TOKEN_ID)).toBeGreaterThan(0n); + expect(tokenInputSum(txTokenGive, TOKEN_ID)).toBe( + BigInt(GIVE_TOKEN_ATOMS) + tokenOutputSum(txTokenGive, TOKEN_ID), + ); + + // coin side only carries the tx fee + expect(coinInputSum(txTokenGive)).toBe( + coinTransferSum(txTokenGive) + BigInt(txTokenGive.JSONRepresentation.fee.atoms), + ); + }); + + test('forgeTransaction signs the built transaction via the wallet request', async () => { + const client = await createConnectedClient(); + + const args = { + outputs: [coinTransferOutput(USER_ADDRESS, '1000000000000')], + }; + + const signed = await client.forgeTransaction(args); + expect(signed).toBe('signed-transaction'); + + expect(mocks.mojito.request).toHaveBeenCalledTimes(1); + const [method, params] = mocks.mojito.request.mock.calls[0]; + expect(method).toBe('signTransaction'); + + // the tx handed to the wallet is exactly the one buildRawTransaction produces + const built = (await client.buildRawTransaction(args)) as LooseBuiltTransaction; + expect(params.txData.JSONRepresentation).toEqual(built.JSONRepresentation); + expect(params.txData.transaction_id).toBe(built.transaction_id); + expect(params.txData.HEXRepresentation_unsigned).toBe(built.HEXRepresentation_unsigned); + }); + + test('accepts zero amounts: the decimal is recomputed as "0"', async () => { + const client = await createConnectedClient(); + + const tx = (await client.buildRawTransaction({ + outputs: [coinTransferOutput(USER_ADDRESS, '0')], + })) as LooseBuiltTransaction; + + const zeroOutput = tx.JSONRepresentation.outputs.find( + (o: any) => o.type === 'Transfer' && o.destination === USER_ADDRESS, + ); + expect(zeroOutput.value.amount.atoms).toBe('0'); + // atomsToDecimal('0', 11) used to return '0.' (dead fallback), rejecting legitimate zero amounts + expect(zeroOutput.value.amount.decimal).toBe('0'); + }); +}); + +describe('buildRawTransaction normalization rejections', () => { + test('rejects decimal spoofing: decimal must equal atoms / 1e11 for Coin', async () => { + const client = await createConnectedClient(); + + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '50000000000000', decimal: '0.005' } }, + }, + ], + }), + ).rejects.toThrow('amount.decimal "0.005" does not match 50000000000000 atoms at 11 decimals ("500")'); + }); + + test('rejects hex/string mismatch on metadata_uri', async () => { + const client = await createConnectedClient(); + + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: { hex: 'aabb', string: 'x' }, + number_of_decimals: 11, + token_ticker: 'XYZ2', + total_supply: { type: 'Unlimited' }, + }, + ], + }), + ).rejects.toThrow('metadata_uri.hex does not match metadata_uri.string'); + }); + + test('rejects oversized ticker and DataDeposit data with length-cap messages', async () => { + const client = await createConnectedClient(); + + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: 'ipfs://x', + number_of_decimals: 11, + token_ticker: 'X'.repeat(33), // cap is 32 + total_supply: { type: 'Unlimited' }, + }, + ], + }), + ).rejects.toThrow('token_ticker must be at most 32 characters'); + + await expect( + client.buildRawTransaction({ + outputs: [{ type: 'DataDeposit', data: 'a'.repeat(4097) }], // cap is 4096 + }), + ).rejects.toThrow('data must be at most 4096 characters'); + }); + + test('rejects a token details payload with a non-numeric next_nonce', async () => { + setupApiMocks({ tokens: { [TOKEN_ID]: { ...MOCK_TOKEN, next_nonce: '7' } } }); + const client = await createConnectedClient(); + + // string next_nonce must be rejected + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'TokenV1', token_id: TOKEN_ID, amount: { atoms: TOKEN_ATOMS(10n), decimal: '10' } }, + }, + ], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(10n), decimal: '10' }, + }, + }, + ], + }), + ).rejects.toThrow('returned an invalid next_nonce: 7'); + }); + + test('rejects a garbage token details payload', async () => { + setupApiMocks({ tokens: { [TOKEN_ID]: { garbage: true } } }); + const client = await createConnectedClient(); + + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'TokenV1', token_id: TOKEN_ID, amount: { atoms: TOKEN_ATOMS(10n), decimal: '10' } }, + }, + ], + }), + ).rejects.toThrow('number_of_decimals must be an integer between 0 and 18'); + }); + + test('rejects an IssueNft output whose caller token_id differs from the derived one', async () => { + const client = await createConnectedClient(); + + // passes validateRawId (bech32-like charset/length) but differs from the id + // derived from the transaction inputs; rejected instead of overwritten + await expect( + client.buildRawTransaction({ + outputs: [ + { + type: 'IssueNft', + destination: USER_ADDRESS, + token_id: 'tmltk1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', + data: NFT_DATA, + }, + ], + }), + ).rejects.toThrow('IssueNft token_id mismatch'); + }); +}); + +describe('mint netting and nonce sequencing', () => { + test('mints are netted against same-token outputs: only the surplus comes from token UTXOs', async () => { + const client = await createConnectedClient(); + + const MINT_ATOMS = TOKEN_ATOMS(100n); // mint 100 + const OUT_ATOMS = TOKEN_ATOMS(150n); // transfer 150 + + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'TokenV1', token_id: TOKEN_ID, amount: { atoms: OUT_ATOMS, decimal: '150' } }, + }, + ], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: MINT_ATOMS, decimal: '100' }, + }, + }, + ], + })) as LooseBuiltTransaction; + + // mint input preserved with auto nonce from token details + const mint = tx.JSONRepresentation.inputs.find((i: any) => i.input.command === 'MintTokens'); + expect(mint.input.nonce).toBe(7); + expect(mint.input.amount).toEqual({ atoms: MINT_ATOMS, decimal: '100' }); + + // token UTXOs cover only the net 150 - 100 = 50 tokens (conservation asserted) + const netFromUtxos = tokenInputSum(tx, TOKEN_ID) - tokenOutputSum(tx, TOKEN_ID, o => o.destination === changeAddresses[0]); + expect(netFromUtxos).toBe(BigInt(OUT_ATOMS) - BigInt(MINT_ATOMS)); + + // full token conservation including the mint: UTXO inputs + minted = outputs + expect(tokenInputSum(tx, TOKEN_ID) + BigInt(MINT_ATOMS)).toBe(tokenOutputSum(tx, TOKEN_ID)); + + // coin side: mint supply-change fee enters the coin requirement + const supplyFee = BigInt(token_supply_change_fee(FEE_BLOCK_HEIGHT, Network.Testnet).atoms()); + expect(coinInputSum(tx)).toBe(coinTransferSum(tx) + BigInt(tx.JSONRepresentation.fee.atoms) + supplyFee); + }); + + test('explicit nonce below next_nonce throws and explicit nonces advance the per-token counter', async () => { + const client = await createConnectedClient(); + + // next_nonce of the mocked token is 7 → nonce 3 is stale + await expect( + client.buildRawTransaction({ + outputs: [coinTransferOutput(USER_ADDRESS, '1000000000000')], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + nonce: 3, + amount: { atoms: TOKEN_ATOMS(100n), decimal: '100' }, + }, + }, + ], + }), + ).rejects.toThrow('is below the next expected nonce 7'); + + // explicit nonce wins and advances the counter: the following auto input continues from it + const tx = (await client.buildRawTransaction({ + outputs: [coinTransferOutput(USER_ADDRESS, '1000000000000')], + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + nonce: 42, + amount: { atoms: TOKEN_ATOMS(100n), decimal: '100' }, + }, + }, + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(50n), decimal: '50' }, + }, + }, + ], + })) as LooseBuiltTransaction; + + const mints = tx.JSONRepresentation.inputs.filter((i: any) => i.input.command === 'MintTokens'); + expect(mints).toHaveLength(2); + expect(mints[0].input.nonce).toBe(42); + expect(mints[1].input.nonce).toBe(43); // explicit 42 advanced the counter past next_nonce 7 + }); +}); diff --git a/packages/sdk/tests/signer.test.ts b/packages/sdk/tests/signer.test.ts index 6600ecb..8a09401 100644 --- a/packages/sdk/tests/signer.test.ts +++ b/packages/sdk/tests/signer.test.ts @@ -1,7 +1,13 @@ import { AccountProvider, Client, Signer } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; import { addresses, utxos, private_keys } from './__mocks__/accounts/account_signer' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; class TestAccountProvider implements AccountProvider { async connect() { @@ -29,88 +35,12 @@ class TestAccountProvider implements AccountProvider { } beforeEach(() => { - fetchMock.resetMocks(); - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + setupApiMocks({ + utxos, + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, }); }); diff --git a/packages/sdk/tests/staking-create-delegation.test.ts b/packages/sdk/tests/staking-create-delegation.test.ts index c2bd1c3..a7803b7 100644 --- a/packages/sdk/tests/staking-create-delegation.test.ts +++ b/packages/sdk/tests/staking-create-delegation.test.ts @@ -1,39 +1,10 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_03_single_utxo'; +import { utxos } from './__mocks__/accounts/account_03_single_utxo'; +import { setupApiMocks } from './helpers/api-mocks'; beforeEach(() => { - fetchMock.resetMocks(); - - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async (req) => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); - }); + setupApiMocks({ utxos }); }); test('create delegation uses a single coin UTXO to calculate the fee', async () => { diff --git a/packages/sdk/tests/staking.test.ts b/packages/sdk/tests/staking.test.ts index 1937c96..172756d 100644 --- a/packages/sdk/tests/staking.test.ts +++ b/packages/sdk/tests/staking.test.ts @@ -1,30 +1,38 @@ import { Client } from '../src/mintlayer-connect-sdk'; import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + createConnectedClient, + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + expectRejectionWithoutProviderCalls, + setupApiMocks, +} from './helpers/api-mocks'; + import { pool_01 } from './__mocks__/pools/pool_01'; -beforeEach(() => { - fetchMock.resetMocks(); +/** + * Well-formed but unknown pool id: `validateRawId` rejects malformed ids + * (underscores etc.) BEFORE the fetch, so this fixture preserves the test's + * intent — unknown pool → 'Failed to fetch delegation id'. + */ +const UNKNOWN_POOL_ID = 'tpool1wrongpoolid000000000000000000000000000000000000000000qqqqqqqqqq'; - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; +let mocks: ReturnType; - fetchMock.doMock(); +beforeEach(() => { + mocks = setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, + }); fetchMock.mockResponse(async req => { const url = req.url; - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - if (url.includes('/pool/')) { const poolId = url.split('/pool/')[1].split('/delegations')[0]; if(poolId === 'tpool1tl784md209n53kuuwqxu68zav5lu5pdg8ca7kuhs6jg5lw24827q6qgxkc') { @@ -55,7 +63,7 @@ beforeEach(() => { } ]); } - if(poolId === 'wrong_pool_id') { + if(poolId === UNKNOWN_POOL_ID) { return { body: JSON.stringify(JSON.stringify({"error":"Invalid pool Id"})), status: 400, @@ -66,66 +74,6 @@ beforeEach(() => { } } - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - } - if (url.includes('/delegation/')) { const delegationId = url.split('/delegation/').pop(); if(delegationId === 'tdelg1d57nmkp24k0rh0fgsjnjy78wxql8wvgr420ncdsesvssvdgfcg6sx6262w') { @@ -142,16 +90,7 @@ beforeEach(() => { } } - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + return mocks.defaultRouter(req); }); }); @@ -215,7 +154,7 @@ test('delegate staking providing only wrong pool_id', async () => { await client.connect(); await expect(client.delegationStake({ - pool_id: 'wrong_pool_id', + pool_id: UNKNOWN_POOL_ID, amount: 10, })).rejects.toThrow('Failed to fetch delegation id') }) @@ -248,3 +187,24 @@ test('staking withdraw - snapshot', async () => { expect(result).toMatchSnapshot(); spy.mockRestore(); }) + +test('delegation withdraw with an amount smaller than the fee is rejected', async () => { + const client = await createConnectedClient(); + + // 1e-9 ML = 100 atoms, far below the size-based transaction fee + await expect( + client.delegationWithdraw({ + delegation_id: 'tdelg1d57nmkp24k0rh0fgsjnjy78wxql8wvgr420ncdsesvssvdgfcg6sx6262w', + amount: 1e-9, + }), + ).rejects.toThrow('DelegationWithdraw amount is smaller than the transaction fee'); +}); + +test('delegation stake with a malformed pool id is rejected before any provider call', async () => { + const client = await createConnectedClient(); + + await expectRejectionWithoutProviderCalls( + client.delegationStake({ pool_id: 'bad_id', amount: 10 }), + 'pool_id has an invalid format', + ); +}); diff --git a/packages/sdk/tests/standalone-providers.test.ts b/packages/sdk/tests/standalone-providers.test.ts index 69a74ef..b29e96a 100644 --- a/packages/sdk/tests/standalone-providers.test.ts +++ b/packages/sdk/tests/standalone-providers.test.ts @@ -1,5 +1,6 @@ import { Client, MnemonicAccountProvider, PrivateKeyAccountProvider } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; + +import { setupApiMocks } from './helpers/api-mocks'; const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; @@ -30,18 +31,10 @@ const UTXOS = [ }, ]; -function setupFetchMock() { - fetchMock.resetMocks(); - fetchMock.doMock(); - fetchMock.mockResponse(async (req) => { - if (req.url.endsWith('/chain/tip')) return JSON.stringify({ height: 200000 }); - if (req.url.endsWith('/batch')) return JSON.stringify({ results: [UTXOS] }); - return JSON.stringify({}); - }); -} - describe('MnemonicAccountProvider', () => { - beforeEach(setupFetchMock); + beforeEach(() => { + setupApiMocks({ utxos: UTXOS }); + }); test('derives correct addresses from mnemonic', async () => { const provider = new MnemonicAccountProvider(TEST_MNEMONIC, 'testnet'); @@ -86,7 +79,9 @@ describe('MnemonicAccountProvider', () => { }); describe('PrivateKeyAccountProvider', () => { - beforeEach(setupFetchMock); + beforeEach(() => { + setupApiMocks({ utxos: UTXOS }); + }); test('returns provided addresses', async () => { const provider = new PrivateKeyAccountProvider( diff --git a/packages/sdk/tests/token-commands.test.ts b/packages/sdk/tests/token-commands.test.ts index fea030a..2ca88c9 100644 --- a/packages/sdk/tests/token-commands.test.ts +++ b/packages/sdk/tests/token-commands.test.ts @@ -1,100 +1,19 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; beforeEach(() => { - fetchMock.resetMocks(); - - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, }); }); diff --git a/packages/sdk/tests/transaction-decode.test.ts b/packages/sdk/tests/transaction-decode.test.ts new file mode 100644 index 0000000..b2356b8 --- /dev/null +++ b/packages/sdk/tests/transaction-decode.test.ts @@ -0,0 +1,733 @@ +// Pins the refactored decode/assemble split: +// +// 1. `Transaction.fromHEX` decodes ALL transaction input/output variants into +// the explorer-style JSON shape (`inputs[]`, `outputs[]`, `id`). +// 2. `Transaction.assembleRaw` is the single assembly engine used by +// `client.buildRawTransaction` — so re-encoding a decoded transaction +// through `getTransactionBINrepresentation` + `encode_transaction` must +// reproduce the exact unsigned bytes the assembler produced. +// +// Round-trip fixtures use UTXOs whose outpoint indexes align with their input +// positions (index 0 coin + index 1 token) — see the note on `decodedInput` +// index handling in the module report for why index-aligned fixtures are used. +import fetchMock from 'jest-fetch-mock'; +import { encode_transaction } from '@mintlayer/wasm-lib'; +import { BuiltTransaction } from '../src/mintlayer-connect-sdk'; +import { FEE_BLOCK_HEIGHT, Transaction } from '../src/transaction'; +import { atomsToDecimal, mergeUint8Arrays } from '../src/utils'; +import { createConnectedClient, MOCK_TOKEN_AUTHORITY, MOCK_TOKEN_ID, setupApiMocks } from './helpers/api-mocks'; + +import { addresses, utxos as ACCOUNT_UTXOS } from './__mocks__/accounts/account_01'; + +const receiving = addresses.addressesByChain.mintlayer.receiving; +const changeAddresses = addresses.addressesByChain.mintlayer.change; + +const USER_ADDRESS = receiving[0]; +const FEE_ADDRESS = receiving[1]; +const TOKEN_AUTHORITY = MOCK_TOKEN_AUTHORITY; +const TOKEN_ID = MOCK_TOKEN_ID; +/** Decimals of the mocked token fixture (8). */ +const TOKEN_DECIMALS: number = 8; +const TOKEN_ATOMS = (tokens: bigint) => (tokens * BigInt(10 ** TOKEN_DECIMALS)).toString(); + +/** + * Assertion view of the public build result (same shape as the + * `raw-transaction.test.ts` helper): the `inputs`/`outputs`/`fee` unions are + * loosened so the tests can index into discriminated fields directly. + */ +type LooseBuiltTransaction = Omit & { + JSONRepresentation: Omit & { + inputs: any[]; + outputs: any[]; + fee: { atoms: string; decimal: string }; + }; +}; + +/** + * UTXO fixtures for the round-trip tests: the coin UTXO carries outpoint + * index 0 and the token UTXO outpoint index 1. The assembler orders inputs + * [coin..., token...], so each decoded input's array position coincides with + * its true outpoint index and decode → re-encode is byte-exact end to end. + */ +const ROUNDTRIP_UTXOS = [ + { + outpoint: { + index: 0, + source_id: 'af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab', + source_type: 'Transaction', + }, + utxo: { + destination: USER_ADDRESS, + type: 'Transfer', + // same scale as the account_01 coin fixtures — large enough to cover + // the data-deposit and issuance protocol fees at FEE_BLOCK_HEIGHT + value: { type: 'Coin', amount: { atoms: '1703205604300000', decimal: '17032056.043043' } }, + }, + }, + { + outpoint: { + index: 1, + source_id: 'cdcee4a44823978cc50245eff518a562c4461e74fee2ce2f46cade822f657e0d', + source_type: 'Transaction', + }, + utxo: { + destination: changeAddresses[0], + type: 'Transfer', + value: { + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { atoms: '100000000000', decimal: '1000' }, + }, + }, + }, +]; + +/** + * Re-encodes a decoded transaction from its explorer-style JSON: + * `getTransactionBINrepresentation` binarises the decoded inputs/outputs and + * `encode_transaction` assembles the unsigned bytes. The result must equal + * the assembler's `HEXRepresentation_unsigned` byte for byte. + */ +function reencodeHex(rt: Transaction): string { + const json = rt.json() as any; + // `getTransactionBINrepresentation` reads `input.utxo.destination` only for + // its signed-size estimate — the encoded bytes never contain it, and the + // decode shape intentionally drops the utxo payload (real consumers restore + // it via `enrichUtxo`). A syntactically valid address keeps the size + // estimator happy without influencing byte equality. + const binInput = { + ...json, + inputs: json.inputs.map((entry: any) => + entry.input?.input_type === 'UTXO' ? { ...entry, utxo: { destination: USER_ADDRESS } } : entry, + ), + }; + const bin = rt.getTransactionBINrepresentation(binInput, 1, Number(FEE_BLOCK_HEIGHT)); + const bytes = encode_transaction(mergeUint8Arrays(bin.inputs), mergeUint8Arrays(bin.outputs), BigInt(0)); + return bytes.reduce((acc, b) => acc + b.toString(16).padStart(2, '0'), ''); +} + +/** + * Builds a raw transaction through the public client API and decodes its + * unsigned hex back into a `Transaction`. `options.utxos` swaps the served + * UTXO fixtures before the client is created; `options.inputs` passes + * explicit (account-command) inputs through to `buildRawTransaction`. + */ +async function buildAndDecode( + outputs: any[], + options: { utxos?: any[]; inputs?: any[] } = {}, +): Promise<{ tx: LooseBuiltTransaction; rt: Transaction; json: any }> { + if (options.utxos) { + setupApiMocks({ utxos: options.utxos }); + } + const client = await createConnectedClient(); + const tx = (await client.buildRawTransaction({ + outputs, + inputs: options.inputs, + })) as LooseBuiltTransaction; + const rt = Transaction.fromHEX(tx.HEXRepresentation_unsigned, { network: 'testnet' }); + return { tx, rt, json: rt.json() as any }; +} + +beforeEach(() => { + fetchMock.enableMocks(); + setupApiMocks(); +}); + +afterEach(() => { + fetchMock.disableMocks(); +}); + +describe('Transaction.fromHEX decode + re-encode round-trip', () => { + test('Transfer (Coin and TokenV1) round-trips byte-identically and keeps token_id', async () => { + const { tx, rt, json } = await buildAndDecode( + [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000', decimal: atomsToDecimal('1000000', 11) } }, + }, + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(3n), decimal: atomsToDecimal(TOKEN_ATOMS(3n), TOKEN_DECIMALS) }, + }, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + // outputs[0]: the Coin transfer — decimal recomputed at the fixed 11 + // coin decimals, exactly as the assembler normalizes it + expect(json.outputs[0].type).toBe('Transfer'); + expect(json.outputs[0].destination).toBe(USER_ADDRESS); + expect(json.outputs[0].value).toEqual({ + type: 'Coin', + amount: { atoms: '1000000', decimal: '0.00001' }, + }); + + // outputs[1]: the TokenV1 transfer keeps its token_id; the token decimal + // is a placeholder equal to the atoms string by design (token decimals + // are not recoverable at decode time — consumers format via metadata) + expect(json.outputs[1].value).toEqual({ + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(3n), decimal: TOKEN_ATOMS(3n) }, + }); + + // every input decodes as a canonical UTXO input + expect(json.inputs.length).toBeGreaterThan(0); + for (const entry of json.inputs) { + expect(entry.input.input_type).toBe('UTXO'); + expect(entry.input.source_id).toMatch(/^[0-9a-f]{64}$/); + expect(entry.input.source_type).toBe('Transaction'); + } + + // transaction identity survives the decode + expect(json.id).toBe(tx.transaction_id); + expect(json.id).toMatch(/^[0-9a-f]{64}$/); + + // the decoded JSON re-encodes to the exact unsigned bytes + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + }); + + test('LockThenTransfer round-trips with a canonical lock', async () => { + const { tx, rt, json } = await buildAndDecode( + [ + { + type: 'LockThenTransfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000', decimal: atomsToDecimal('1000000', 11) } }, + lock: { type: 'ForBlockCount', content: 10 }, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + expect(json.outputs[0].type).toBe('LockThenTransfer'); + // the numeric lock content decodes canonically as a string (BigInt is + // applied again at encoding time) + expect(json.outputs[0].lock).toEqual({ type: 'ForBlockCount', content: '10' }); + expect(json.outputs[0].destination).toBe(USER_ADDRESS); + + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + }); + + test('DataDeposit, BurnToken, CreateDelegationId, DelegateStaking round-trip', async () => { + const cases: any[] = [ + { type: 'DataDeposit', data: 'hello explorer' }, + { + type: 'BurnToken', + value: { type: 'Coin', amount: { atoms: '123', decimal: atomsToDecimal('123', 11) } }, + }, + { + type: 'BurnToken', + value: { + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { atoms: '7', decimal: atomsToDecimal('7', TOKEN_DECIMALS) }, + }, + }, + { + type: 'CreateDelegationId', + destination: USER_ADDRESS, + pool_id: 'tpool1dwpe7zy0mhagnwl36ywt5q20xxvu5dwmph4z6q8sc0a3srz5h8jqr0r2yg', + }, + { + type: 'DelegateStaking', + delegation_id: 'tdelg1d57nmkp24k0rh0fgsjnjy78wxql8wvgr420ncdsesvssvdgfcg6sx6262w', + amount: { atoms: '2000', decimal: atomsToDecimal('2000', 11) }, + }, + ]; + + for (const output of cases) { + const { tx, rt, json } = await buildAndDecode([output], { utxos: ROUNDTRIP_UTXOS }); + + expect(json.outputs[0].type).toBe(output.type); + // the decoded JSON re-encodes to the exact unsigned bytes for every + // one of these output variants + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + } + }); + + test('IssueFungibleToken decodes {hex,string} fields and supply type', async () => { + const { tx, rt, json } = await buildAndDecode( + [ + { + type: 'IssueFungibleToken', + authority: TOKEN_AUTHORITY, + is_freezable: false, + metadata_uri: 'ipfs://QmExampleHash123', + number_of_decimals: 11, + token_ticker: 'XYZ2', + total_supply: { type: 'Fixed', amount: { atoms: '1000000000000', decimal: '10' } }, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + const issuance = json.outputs[0]; + expect(issuance.type).toBe('IssueFungibleToken'); + expect(issuance.authority).toBe(TOKEN_AUTHORITY); + expect(issuance.is_freezable).toBe(false); + expect(issuance.number_of_decimals).toBe(11); + // byte fields decode as {hex, string} pairs + expect(issuance.token_ticker).toEqual({ hex: '58595a32', string: 'XYZ2' }); + expect(issuance.metadata_uri.string).toBe('ipfs://QmExampleHash123'); + // a Fixed supply decodes back into the tagged {type, amount} shape + expect(issuance.total_supply).toEqual({ + type: 'Fixed', + amount: { atoms: '1000000000000', decimal: '10' }, + }); + + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + }); + + test('Htlc and CreateOrder round-trip', async () => { + // (a) Htlc with a Coin value + const htlc = await buildAndDecode( + [ + { + type: 'Htlc', + value: { type: 'Coin', amount: { atoms: '900', decimal: atomsToDecimal('900', 11) } }, + htlc: { + spend_key: USER_ADDRESS, + refund_key: USER_ADDRESS, + secret_hash: '0000000000000000000000000000000000000000', + refund_timelock: { type: 'ForBlockCount', content: 10 }, + }, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + const htlcOut = htlc.json.outputs[0]; + expect(htlcOut.type).toBe('Htlc'); + // the secret hash decodes as a hex field + expect(htlcOut.htlc.secret_hash.hex).toBe('0000000000000000000000000000000000000000'); + expect(htlcOut.htlc.refund_timelock).toEqual({ type: 'ForBlockCount', content: '10' }); + expect(reencodeHex(htlc.rt)).toBe(htlc.tx.HEXRepresentation_unsigned); + + // (b) CreateOrder asking Coin, giving the token + const order = await buildAndDecode( + [ + { + type: 'CreateOrder', + ask_balance: { atoms: '1000', decimal: atomsToDecimal('1000', 11) }, + ask_currency: { type: 'Coin' }, + give_balance: { atoms: '2000', decimal: atomsToDecimal('2000', TOKEN_DECIMALS) }, + give_currency: { type: 'TokenV1', token_id: TOKEN_ID }, + initially_asked: { atoms: '1000', decimal: atomsToDecimal('1000', 11) }, + initially_given: { atoms: '2000', decimal: atomsToDecimal('2000', TOKEN_DECIMALS) }, + conclude_destination: USER_ADDRESS, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + const orderOut = order.json.outputs[0]; + expect(orderOut.type).toBe('CreateOrder'); + expect(orderOut.ask_currency).toEqual({ type: 'Coin' }); + expect(orderOut.give_currency).toEqual({ type: 'TokenV1', token_id: TOKEN_ID }); + expect(orderOut.conclude_destination).toBe(USER_ADDRESS); + expect(reencodeHex(order.rt)).toBe(order.tx.HEXRepresentation_unsigned); + }); + + test('AccountCommand inputs decode with command fields and round-trip', async () => { + // default account_01 fixtures: the mocked token exposes next_nonce 7 and + // the selected UTXO lands at input position 1 with outpoint index 1 + const { tx, rt, json } = await buildAndDecode( + [ + { + type: 'Transfer', + destination: FEE_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000', decimal: atomsToDecimal('1000', 11) } }, + }, + ], + { + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'MintTokens', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(5n), decimal: atomsToDecimal(TOKEN_ATOMS(5n), TOKEN_DECIMALS) }, + }, + }, + ], + }, + ); + + const command = json.inputs.find((entry: any) => entry.input.input_type === 'AccountCommand'); + expect(command).toBeDefined(); + // casing regression guard: the command name decodes as the SDK's + // canonical 'MintTokens', not the wasm tag or an uppercased variant + expect(command.input.command).toBe('MintTokens'); + expect(command.input.token_id).toBe(TOKEN_ID); + expect(command.input.amount.atoms).toBe(TOKEN_ATOMS(5n)); + // nonce auto-looked-up from the mocked token details (next_nonce 7) + expect(command.input.nonce).toBe(7); + + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + }); + + test('IssueNft decode keeps data fields', async () => { + const { json } = await buildAndDecode( + [ + { + type: 'IssueNft', + destination: USER_ADDRESS, + data: { + name: 'NftName', + ticker: 'NFT', + description: 'desc', + media_hash: 'deadbeef', + media_uri: 'ipfs://media', + icon_uri: 'ipfs://icon', + additional_metadata_uri: 'ipfs://extra', + }, + }, + ], + { utxos: ROUNDTRIP_UTXOS }, + ); + + const nft = json.outputs[0]; + expect(nft.type).toBe('IssueNft'); + // string metadata decodes as {hex, string} pairs + expect(nft.data.name.string).toBe('NftName'); + expect(nft.data.ticker.string).toBe('NFT'); + // the media hash round-trips through its utf-8 bytes: the encoder stores + // the ascii bytes of "deadbeef", so the decoded hex is that byte string + expect(nft.data.media_hash.hex).toBe('6465616462656566'); + expect(nft.data.media_hash.string).toBe('deadbeef'); + expect(nft.data.media_uri.string).toBe('ipfs://media'); + // the token id derived from the inputs is backfilled before encoding and + // survives the decode + expect(nft.token_id).toBeTruthy(); + // NOTE: no round-trip assertion here — the decode intentionally drops the + // IssueNft destination (the wasm decode shape lacks it), so the decoded + // JSON cannot be re-encoded losslessly for this output type. + }); + + test('fee parity: fluent builder vs raw assembler', async () => { + const client = await createConnectedClient(); + + const rawTx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: FEE_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000', decimal: atomsToDecimal('1000000', 11) } }, + }, + ], + })) as LooseBuiltTransaction; + + // the fluent class drives the same assembly engine with the same UTXO + // set the mocked API serves, so the converged fee must be identical + const fluent = new Transaction({ network: 'testnet' }) + .setChangeAddress(changeAddresses[0]) + .addOutput({ + type: 'Transfer', + destination: FEE_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000000', decimal: '0.001' } }, + } as any) + .withUTXO(ACCOUNT_UTXOS) + .build(); + + expect(fluent.getFee().atoms).toBe(rawTx.JSONRepresentation.fee.atoms); + }); +}); + +describe('Review-round regression pins', () => { + test('UnmintTokens decodes the bare token-id payload with auto nonce and round-trips', async () => { + const { tx, rt, json } = await buildAndDecode( + [ + { + type: 'Transfer', + destination: FEE_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000', decimal: atomsToDecimal('1000', 11) } }, + }, + ], + { + inputs: [ + { + input: { + input_type: 'AccountCommand', + command: 'UnmintTokens', + token_id: TOKEN_ID, + amount: { atoms: TOKEN_ATOMS(2n) }, + }, + }, + ], + }, + ); + + const command = json.inputs.find((entry: any) => entry.input?.input_type === 'AccountCommand'); + expect(command).toBeDefined(); + expect(command.input.command).toBe('UnmintTokens'); + // regression: the wasm decode payload for UnmintTokens is a BARE token-id + // string (no [token_id, amount] pair) — the old `second.atoms` accessor + // used to crash here + expect(command.input.token_id).toBe(TOKEN_ID); + // mock token next_nonce is 7 → auto nonce 7 + expect(command.input.nonce).toBe(7); + // The amount never enters the bytes in either direction: + // `encode_input_for_unmint_tokens` encodes only (token_id, nonce) and the + // decode payload has no amount — so the round-trip stays byte-identical. + expect(reencodeHex(rt)).toBe(tx.HEXRepresentation_unsigned); + }); + + test('assembleRaw guards: UTXOs and change address are required', () => { + // no withUTXO at all → hard guard (previously crashed deep in selection) + expect(() => + Transaction.assembleRaw({ outputs: [], inputs: [], requiredCoin: 0n, requiredToken: 0n, baseFee: 0n } as any, { + network: 'testnet', + changeAddress: changeAddresses[0], + }), + ).toThrow('UTXOs are required'); + + // same guard with an actual coin requirement (empty UTXO plan) + expect(() => + Transaction.assembleRaw( + { + outputs: [{ type: 'Transfer', destination: USER_ADDRESS, value: { type: 'Coin', amount: { atoms: '1' } } }], + inputs: [], + requiredCoin: 1n, + requiredToken: 0n, + baseFee: 0n, + } as any, + { network: 'testnet', changeAddress: changeAddresses[0] }, + ), + ).toThrow('UTXOs are required'); + + // missing change address → guard fires before any assembly work + expect(() => + Transaction.assembleRaw( + { + outputs: [], + inputs: [], + requiredCoin: 0n, + requiredToken: 0n, + baseFee: 0n, + withUTXO: [], + } as any, + { network: 'testnet', changeAddress: '' }, + ), + ).toThrow('change address'); + }); + + test('forceSpendUtxo spends the given outpoint exactly once (no double spend vs auto-selection)', async () => { + const client = await createConnectedClient(); + + // the largest coin UTXO of the mocked account — auto-selection would pick + // it for any transfer, so forcing it MUST evict it from auto-selection + const forcedEntry = { + outpoint: { + index: 1, + source_id: 'af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab', + source_type: 'Transaction', + }, + utxo: { + destination: 'tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6', + type: 'Transfer', + value: { type: 'Coin', amount: { atoms: '1703205604300000', decimal: '17032.056043' } }, + }, + }; + + const outputs = [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000', decimal: atomsToDecimal('1000', 11) } }, + }, + ]; + + // baseline: without force, auto-selection spends the outpoint once + const plain = (await client.buildRawTransaction({ outputs: outputs as any })) as LooseBuiltTransaction; + const plainSpent = plain.JSONRepresentation.inputs.filter( + (i: any) => + i.input?.input_type === 'UTXO' && i.input.source_id === forcedEntry.outpoint.source_id && i.input.index === 1, + ); + expect(plainSpent).toHaveLength(1); + + // forced build must not crash … + const forced = (await client.buildRawTransaction({ + outputs: outputs as any, + opts: { forceSpendUtxo: [forcedEntry as any] }, + })) as LooseBuiltTransaction; + + const spent = forced.JSONRepresentation.inputs.filter( + (i: any) => + i.input?.input_type === 'UTXO' && i.input.source_id === forcedEntry.outpoint.source_id && i.input.index === 1, + ); + // … and the forced outpoint appears EXACTLY ONCE — a duplicate entry would + // be a double spend inside one transaction plus double-counted change math + expect(spent).toHaveLength(1); + expect(spent[0].utxo.value.amount.atoms).toBe('1703205604300000'); + }); + + test('forceSpendUtxo accepts an HTLC-shaped entry alongside a plain transfer', async () => { + const client = await createConnectedClient(); + + // HTLC UTXOs are never auto-selected; a forced HTLC entry must assemble + // with just its outpoint (witness handling is the signer's job) + const htlcOutpoint = { + index: 0, + source_id: '1111111111111111111111111111111111111111111111111111111111111111', + source_type: 'Transaction', + }; + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000', decimal: atomsToDecimal('1000', 11) } }, + }, + ], + opts: { + forceSpendUtxo: [ + { + outpoint: htlcOutpoint, + utxo: { + type: 'Htlc', + value: { type: 'Coin', amount: { atoms: '1000000000000', decimal: '10' } }, + htlc: { + secret_hash: '0000000000000000000000000000000000000000', + spend_key: USER_ADDRESS, + refund_key: USER_ADDRESS, + refund_timelock: { type: 'ForBlockCount', content: 10 }, + }, + }, + } as any, + ], + }, + })) as LooseBuiltTransaction; + + const htlcInput = tx.JSONRepresentation.inputs.find( + (i: any) => i.input?.input_type === 'UTXO' && i.input.source_id === htlcOutpoint.source_id && i.input.index === 0, + ); + expect(htlcInput).toBeDefined(); + expect(htlcInput.utxo.type).toBe('Htlc'); + }); + + test('DelegationWithdraw-style fee deduction does not compound across fee iterations', () => { + const AMOUNT = '1099511627777'; // 2^40 + 1 — varint-boundary scale amount + const prepared = { + outputs: [ + { + type: 'LockThenTransfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: AMOUNT, decimal: atomsToDecimal(AMOUNT, 11) } }, + lock: { type: 'ForBlockCount', content: 10 }, + }, + ], + inputs: [], + requiredCoin: 0n, // withdraw: funds come from the delegation input, not coin UTXOs + requiredToken: 0n, + // nonzero so iteration 0 already deducts a REAL fee — with baseFee 0 the + // loop converged in one deduction and cross-iteration compounding was + // invisible to this test + baseFee: 1_000_000_000n, + deductFeeFromFirstOutput: true, + withUTXO: [], + } as any; + const assembled = Transaction.assembleRaw(prepared, { + network: 'testnet', + changeAddress: changeAddresses[0], + }); + + const j = assembled.JSONRepresentation as any; + const outAtoms = BigInt(j.outputs[0].value.amount.atoms); + const feeAtoms = BigInt(j.fee.atoms); + // THE invariant: output + embedded fee === original amount (no compounding, + // no silent burn) — the deduction must be relative to the ORIGINAL amount + // in every fee iteration, never applied onto the previous iteration's + // mutated output value + expect(outAtoms + feeAtoms).toBe(BigInt(AMOUNT)); + + // caller-mutation guard: assembling must leave the caller's prepared + // outputs untouched — the fee-deducted output is written to a deep clone, + // never back onto `prepared.outputs[0]` + expect((prepared.outputs[0] as any).value.amount.atoms).toBe(AMOUNT); + }); + + test('fee conservation across varint-boundary amounts on the normal path', async () => { + const client = await createConnectedClient(); + // 2^32+1, 2^40+1, 2^48+1 — amount encodings flip varint length at these + // scales, which is exactly where the fee loop can change its mind (the + // mocked account holds ~3.8e15 coin atoms, so 2^48 is the largest sweep + // entry that still fits) + const boundaryAmounts = ['4294967297', '1099511627777', '281474976710657']; + + for (const AMOUNT of boundaryAmounts) { + const tx = (await client.buildRawTransaction({ + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: AMOUNT, decimal: atomsToDecimal(AMOUNT, 11) } }, + }, + ], + })) as LooseBuiltTransaction; + + const inputAtoms = tx.JSONRepresentation.inputs + .filter((i: any) => i.input?.input_type === 'UTXO') + .reduce((acc: bigint, i: any) => acc + BigInt(i.utxo.value.amount.atoms), 0n); + const outputAtoms = tx.JSONRepresentation.outputs.reduce( + (acc: bigint, o: any) => acc + BigInt(o.value.amount.atoms), + 0n, + ); + // inputs − outputs === fee: no atoms lost or invented + expect(inputAtoms - outputAtoms).toBe(BigInt(tx.JSONRepresentation.fee.atoms)); + } + }); + + test('HTLC input keys count toward the transaction size estimate', () => { + const clientless = new Transaction({ network: 'testnet' }); + const makeJson = (utxo: any) => ({ + inputs: [ + { input: { index: 0, input_type: 'UTXO', source_id: 'a'.repeat(64), source_type: 'Transaction' }, utxo }, + ], + outputs: [ + { + type: 'Transfer', + destination: USER_ADDRESS, + value: { type: 'Coin', amount: { atoms: '1000', decimal: '0.00001' } }, + }, + ], + id: 'to_be_filled_in', + }); + const htlcUtxo = { + type: 'Htlc', + value: { type: 'Coin', amount: { atoms: '900', decimal: '0.000009' } }, + htlc: { + secret_hash: '0000000000000000000000000000000000000000', + spend_key: USER_ADDRESS, + refund_key: FEE_ADDRESS, + refund_timelock: { type: 'ForBlockCount', content: 10 }, + }, + }; + const plainUtxo = { + type: 'Transfer', + value: { type: 'Coin', amount: { atoms: '900', decimal: '0.000009' } }, + destination: USER_ADDRESS, + }; + + const sizeHtlc = clientless.getTransactionBINrepresentation( + makeJson(htlcUtxo) as any, + 1, + Number(FEE_BLOCK_HEIGHT), + ).transactionsize; + const sizePlain = clientless.getTransactionBINrepresentation( + makeJson(plainUtxo) as any, + 1, + Number(FEE_BLOCK_HEIGHT), + ).transactionsize; + + // HTLC contributes TWO long addresses vs ONE for a plain transfer — + // dropping the keys (the regression) under-estimates and this pin fails + expect(sizeHtlc).toBeGreaterThan(sizePlain); + }); +}); diff --git a/packages/sdk/tests/transfer-nft.test.ts b/packages/sdk/tests/transfer-nft.test.ts index 4d2df6d..97ebe5f 100644 --- a/packages/sdk/tests/transfer-nft.test.ts +++ b/packages/sdk/tests/transfer-nft.test.ts @@ -1,90 +1,27 @@ import { Client } from '../src/mintlayer-connect-sdk'; import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; -beforeEach(() => { - fetchMock.resetMocks(); - - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; +let mocks: ReturnType; - fetchMock.doMock(); +beforeEach(() => { + mocks = setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, + }); fetchMock.mockResponse(async req => { const url = req.url; - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - if (url.includes('/nft/')) { const tokenId = url.split('/nft/').pop(); if (tokenId === 'tmltk1hulyp284e3kc522ta435wyckrqy4j4842perueyge6ctjlp2mpds65mcx8') { @@ -158,16 +95,7 @@ beforeEach(() => { return JSON.stringify({ a: 'b' }); } - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + return mocks.defaultRouter(req); }); }); diff --git a/packages/sdk/tests/transfer-token.test.ts b/packages/sdk/tests/transfer-token.test.ts index 25d2dcd..1d909b0 100644 --- a/packages/sdk/tests/transfer-token.test.ts +++ b/packages/sdk/tests/transfer-token.test.ts @@ -1,100 +1,19 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; beforeEach(() => { - fetchMock.resetMocks(); - - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, }); }); diff --git a/packages/sdk/tests/transfer.test.ts b/packages/sdk/tests/transfer.test.ts index 2d2e9b1..590860b 100644 --- a/packages/sdk/tests/transfer.test.ts +++ b/packages/sdk/tests/transfer.test.ts @@ -1,100 +1,22 @@ import { Client } from '../src/mintlayer-connect-sdk'; import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { + MOCK_TOKEN, + MOCK_TOKEN_11_DECIMALS, + MOCK_TOKEN_11_DECIMALS_ID, + MOCK_TOKEN_ID, + setupApiMocks, +} from './helpers/api-mocks'; + +let mocks: ReturnType; beforeEach(() => { - fetchMock.resetMocks(); - - // эмуляция window.mojito - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if (url.includes('/token/')) { - const tokenId = url.split('/token/').pop(); - if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 8, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { - return JSON.stringify({ - "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", - "circulating_supply": { - "atoms": "209000000000", - "decimal": "2090" - }, - "frozen": false, - "is_locked": false, - "is_token_freezable": true, - "is_token_unfreezable": null, - "metadata_uri": { - "hex": "697066733a2f2f516d4578616d706c6548617368313233", - "string": "ipfs://QmExampleHash123" - }, - "next_nonce": 7, - "number_of_decimals": 11, - "token_ticker": { - "hex": "58595a32", - "string": "XYZ2" - }, - "total_supply": { - "Fixed": { - "atoms": "100000000000000" - } - } - }); - } - return JSON.stringify({ a: 'b' }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); + mocks = setupApiMocks({ + tokens: { + [MOCK_TOKEN_ID]: MOCK_TOKEN, + [MOCK_TOKEN_11_DECIMALS_ID]: MOCK_TOKEN_11_DECIMALS, + }, }); }); @@ -181,7 +103,7 @@ test('transfer ignores account entries without utxo', async () => { nonce: 1, }, }, - ...utxos, + ...mocks.utxos, ]], }), }; diff --git a/packages/sdk/tests/utxo-change.test.ts b/packages/sdk/tests/utxo-change.test.ts index 3874160..4d4a178 100644 --- a/packages/sdk/tests/utxo-change.test.ts +++ b/packages/sdk/tests/utxo-change.test.ts @@ -1,40 +1,10 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -import { addresses, utxos } from './__mocks__/accounts/account_01' +import { setupApiMocks } from './helpers/api-mocks'; import { makeLogger } from 'ts-loader/dist/logger'; beforeEach(() => { - fetchMock.resetMocks(); - - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue(addresses), - restore: jest.fn().mockResolvedValue(addresses), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); - - fetchMock.mockResponse(async req => { - const url = req.url; - - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - if(url.endsWith('/batch')) { - return { - body: JSON.stringify({ - results: [utxos], - }), - }; - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); - }); + setupApiMocks(); }); test('preview utxo after transfer', async () => { diff --git a/packages/sdk/tests/verify-challenge.test.ts b/packages/sdk/tests/verify-challenge.test.ts index 8f623ef..7126597 100644 --- a/packages/sdk/tests/verify-challenge.test.ts +++ b/packages/sdk/tests/verify-challenge.test.ts @@ -1,40 +1,18 @@ import { Client } from '../src/mintlayer-connect-sdk'; -import fetchMock from 'jest-fetch-mock'; -beforeEach(() => { - fetchMock.resetMocks(); - - (window as any).mojito = { - isExtension: true, - connect: jest.fn().mockResolvedValue({ - addressesByChain: { - mintlayer: { - receiving: ['tmt1q9cz2dkuqqrdv2g3kl8zqvjvqtu3u6j3j8z9z9z9'], - change: ['tmt1q9cz2dkuqqrdv2g3kl8zqvjvqtu3u6j3j8z9z9z8'], - }, - }, - }), - restore: jest.fn().mockResolvedValue({ - testnet: { - receiving: ['tmt1q9cz2dkuqqrdv2g3kl8zqvjvqtu3u6j3j8z9z9z9'], - }, - }), - disconnect: jest.fn().mockResolvedValue(undefined), - request: jest.fn().mockResolvedValue('signed-transaction'), - }; - - fetchMock.doMock(); +import { setupApiMocks } from './helpers/api-mocks'; - fetchMock.mockResponse(async req => { - const url = req.url; +const VERIFY_ADDRESSES = { + addressesByChain: { + mintlayer: { + receiving: ['tmt1q9cz2dkuqqrdv2g3kl8zqvjvqtu3u6j3j8z9z9z9'], + change: ['tmt1q9cz2dkuqqrdv2g3kl8zqvjvqtu3u6j3j8z9z9z8'], + }, + }, +}; - if (url.endsWith('/chain/tip')) { - return JSON.stringify({ height: 200000 }); - } - - console.warn('No mock for:', url); - return JSON.stringify({ error: 'No mock defined' }); - }); +beforeEach(() => { + setupApiMocks({ addresses: VERIFY_ADDRESSES }); }); test('verifyChallenge should verify a valid signature', async () => {