Skip to content

Release 1.1.0: generic tx builder, SDK hardening, corporate npm publishing pipeline - #37

Merged
erubboli merged 36 commits into
mainfrom
feat/generic-tx-builder
Sep 18, 2026
Merged

erubboli merged 36 commits into
mainfrom
feat/generic-tx-builder

Conversation

@erubboli

Copy link
Copy Markdown
Member

Summary

  • SDK: generic transaction builder, raw-transaction hardening (fee netting, HTLC/id validation, token data shapes, zero-amount decimals), fee table consolidation, test migration to shared api-mocks (95/95 passing)
  • CI: reconciled publish workflow — workflow_dispatch bound to explicit release tags (sdk-v*/react-v*/wasm-lib-v*), version↔tag verification, SHA-pinned actions, npm-publish environment gate, npm staged publishing with provenance (trusted publishing OIDC — no NPM_TOKEN)
  • packages/sdk: 1.1.0, MIT license, repository/author/publishConfig metadata
  • Removed docs/NPM_PUBLISH_SETUP.md (org migration completed; sensitive access details no longer appropriate for a public repo)

Release plan after merge

  1. Protected-tags ruleset for *-v* (admins only)
  2. Tag sdk-v1.1.0 on main → dispatch publish workflow → staged publish → 2FA approval on npmjs.com
  3. @mintlayer/react onboarding follows (first publish is manual due to npm staged-publishing limitation for new packages)

Move everything not specific to a transaction type out of buildTransaction
into a private assembleTransaction({outputs, inputs, requiredCoin,
requiredToken, sendToken, baseFee, deductFeeFromFirstOutput, opts}).

buildTransaction now only computes the required outputs/inputs and
coin/token requirements via getRequiredInputsOutputs, adds the per-type
fee via getFeeForType and delegates to assembleTransaction. The
DelegationWithdraw special cases (no coin selection, fee deducted from
the first output) are modeled by the deductFeeFromFirstOutput flag.

Public APIs and behavior are unchanged. Reviewer note: subagent tooling
is unavailable in this environment, so the correctness/TS, security and
DRY reviews were run manually on the diff (clean, zero findings). One
invariant change: getRequiredInputsOutputs (pure, synchronous) now runs
before the UTXO fetch instead of after.
Add Client.buildRawTransaction(args) and Client.forgeTransaction(args)
for developer-forged transactions:

- outputs: arbitrary RawOutput[] mirroring the canonical output union,
  accepting plain strings for {hex,string} fields (auto hex-encoded)
  and plain numbers for lock contents
- inputs: optional nonce-based account inputs (mint/unmint/freeze/
  unfreeze/lock-supply/change-authority/change-metadata-uri/fill-order/
  conclude-order/delegation-withdraw). Nonces are auto-looked-up from
  token/order/delegation details (next_nonce) and assigned sequentially
  per token in input order; explicit nonces override. Token-command
  authorities are inferred from token details when omitted.
- Coin requirements are computed from caller outputs (Transfer,
  LockThenTransfer, Htlc, BurnToken, DelegateStaking, CreateOrder give
  side) plus protocol fees (fungible/NFT issuance, data deposit) and
  input fees (supply change, freeze, change authority, fixed
  metadata/unfreeze fees). Minted token outputs are covered by their
  MintTokens input instead of token UTXOs. Custom inputs never satisfy
  coin requirements: UTXO selection for fees/coins always runs and
  change is appended as today (hybrid model, shared assembler).

Also: consolidate getFeeForType onto a shared protocolFee() helper, and
handle UntilTime LockThenTransfer locks (content as timestamp string or
{timestamp} object) which previously crashed during encoding.

Reviewer note: subagent tooling is unavailable in this environment, so
the correctness/TS, security (input validation, prototype pollution via
JSON args, secret leakage) and DRY reviews were run manually on the
diff (clean, zero blocking findings). Spec deviation: mint inputs carry
token_supply_change_fee, following getFeeForType/existing buildMintToken
rather than the task text's 'mint input needs no fee' aside.
Re-export the go-sdk-parity primitives from @mintlayer/wasm-lib via the
package entry point: all encode_output_* output encoders, all
encode_input_for_* input encoders (including freeze_order),
encode_outpoint_source_id, encode_transaction, encode_signed_transaction,
estimate_transaction_size, get_transaction_id, the lock encoders
(block count/seconds/until time/until height), the protocol fee helpers,
encode_stake_pool_data/encode_destination and the supporting enums
(Amount, Network, SourceId, TotalSupply, FreezableToken, TokenUnfreezable,
SignatureHashType) needed to call them.

Re-export only; no wrapper logic.

Reviewer note: subagent tooling is unavailable in this environment, so
the correctness/TS, security and DRY reviews were run manually on the
diff (clean, zero findings).
Publish @mintlayer/sdk when a GitHub release is published (and via
workflow_dispatch). Installs with --frozen-lockfile, runs the SDK test
suite as a gate, then publishes only packages/sdk through
'pnpm --filter @mintlayer/sdk publish --access public --no-git-checks'
using the NPM_TOKEN secret and npm provenance (NPM_CONFIG_PROVENANCE).

Self-reviewed: job permissions are least-privilege (contents: read,
id-token: write — the latter required for provenance signing); the only
secret is the NPM_TOKEN reference scoped to the publish step env;
action versions match the existing test.yml style (checkout@v3,
pnpm/action-setup@v2, setup-node@v3, node 20, pnpm cache).
Document the current npm state for @mintlayer/sdk (user-owned scope,
maintainers owlsua and anyxem, latest published version 1.0.39, no
NPM_TOKEN secret yet) and the one-time maintainer setup: create a
granular access token (read/write, scoped to @mintlayer/sdk, expiry
<= 1 year), add it as the NPM_TOKEN repository secret, and publish by
bumping the version, merging and creating a GitHub Release. Adds a
long-term recommendation to convert @mintlayer to an npm org with a
bot account.

Self-reviewed: no secrets or invented URLs; steps match the publish
workflow added in the previous commit.
Bump @mintlayer/sdk to 1.1.0 (new public API: buildRawTransaction,
forgeTransaction, re-exported wasm encoding primitives). Publishing
happens via the release-triggered workflow; no manual npm publish.

Self-reviewed: lockfile intentionally not modified (no dependency
changes).
New suite packages/sdk/tests/raw-transaction.test.ts covering
Client.buildRawTransaction / forgeTransaction API:

- issuance + transfers + fee output in a single transaction, with coin
  conservation (inputs = outputs + change + tx fee + issuance fee) and
  64-hex transaction id
- multi-transfer with automatic UTXO selection (largest-coin UTXO picked)
  and change output to the first change address
- MintTokens account inputs: sequential auto nonces from token next_nonce
  ([7, 8]), explicit nonce override (42), authority auto-inference
- rejections without any provider calls: empty outputs, unknown output
  type, malformed atoms (negative / decimal / non-numeric), UTXO-style
  input passed in inputs
- wasm primitive re-export sanity (encode_output_transfer,
  encode_transaction, estimate_transaction_size)
- plain-string RawStringField auto-wrapping into {hex, string} pairs

Full suite now: 19 test suites / 76 tests, all green
(previously 18 suites / 66 tests).
… package

The workflow published from source without ever building: dist/ is
gitignored and the package had no 'files' field, so the npm tarball had
no entry point. Fixes:

- packages/sdk: 'files': ['dist'], prepublishOnly guard (test+build)
- workflow: build the SDK before publishing
- workflow: check out the released tag (release events otherwise check
  out default-branch HEAD) and assert package.json version === tag
  (leading 'v' tolerated) before publishing
- workflow: pnpm 10 (lockfile is lockfileVersion 9.0, pnpm 8 cannot
  read it) via pinned pnpm/action-setup@v4
- workflow: pin actions to full commit SHAs (checkout v3 a37ce91,
  setup-node v3 3235b87, pnpm/action-setup v4 b906aff)
- kept --no-git-checks (deliberate reviewer deviation): a tag checkout
  is a detached HEAD so pnpm's default-branch check can never pass;
  the workflow verifies the tag-version match explicitly instead and
  the fresh checkout guarantees a clean worktree
- provenance kept: id-token: write + NPM_CONFIG_PROVENANCE

Self-reviewed: permissions least-privilege (contents: read, id-token:
write); only secret is the NPM_TOKEN reference scoped to the publish
step; SHAs verified via git ls-remote; YAML parse-checked. All 19
suites green. Committed unsigned (--no-gpg-sign) with user
authorization; headless environment has no pinentry.
Reviewer findings #3, #4, #11, #12, #16:

- Wallet-display spoofing: amounts are now normalized as
  normalizeRawAmount(amount, decimals, context); the decimal value is
  always recomputed from the validated atoms via atomsToDecimal (11 for
  Coin, token decimals for TokenV1, the issuance number_of_decimals for
  Fixed supply) and a caller-supplied decimal that disagrees is
  rejected, never echoed. Token-details lookups are promise-cached and
  shared across outputs/inputs.
- hex/string pairs: stringToHex now encodes UTF-8 with zero-padded
  bytes (old impl emitted 1-2 digits per char and mangled non-ASCII);
  provided {hex,string} pairs are charset/even-length validated and
  cross-checked against the recomputed hex; the canonical pair is
  always rebuilt from the string. ASCII outputs are byte-identical
  (all snapshots and existing assertions unchanged).
- Display strings (string fields, DataDeposit.data, IssueNft creator)
  are stripped of C0/C1 control chars and Unicode bidi marks.
- Lock/refund-timelock contents validated ^\d+$ and preserved as
  strings (no Number() precision loss; BigInt applied at encoding);
  Timelock ForBlockCount content widened to string | number.
- rawAtomsString takes a field label so errors name the offending
  field (lock.content/timestamp/fill_atoms) instead of always
  'amount.atoms'.
- SDK-level sanity caps: max 100 outputs; ticker 32, URIs 512, NFT name
  128, description 1024, hash 128, data deposit 4096, creator 128.

Test fixtures: raw-transaction.test.ts (tdd-guide pass) aligned to the
new guard — FEE_ATOMS 1e9->1e12, USER_ATOMS 5e13, two Transfer outputs
1e9->1e12 atoms ('10' ML) and IFT total_supply decimal '1000'->'10'
(1e12 atoms at 11 decimals); the previous atoms/decimal pairs were
internally inconsistent and are now (correctly) rejected by the spoof
guard.

Self-reviewed: all 19 suites + 76 tests green, tsc clean; every
remaining fixture re-verified against atomsToDecimal; promise cache is
per-call so failed lookups cannot leak between requests.
…validation

Reviewer findings #5, #6, #13, #14, #15:

- IssueNft outputs get their token id BEFORE encoding via
  get_token_id(inputs-bytes, height, network): the wasm encoder rejects
  the empty placeholder id with a cryptic error. Input encoding is
  extracted into getTransactionInputsBytes (input bytes do not depend
  on outputs); the post-encode backfill is removed and the JSON now
  always carries the real id. More than one IssueNft output per
  transaction is rejected: get_token_id cannot derive per-output ids,
  so silently reusing one id would be wrong (deviation from the review
  item's 'update ALL' — refusal prevents writing incorrect ids).
- Mint netting: token requirements are netted against same-transaction
  mints per token (previously ANY mint exempted ALL outputs of that
  token, allowing conservation violations). Only the positive remainder
  requires token UTXOs; over-minting is allowed and documented.
- Htlc fee parity: raw Htlc outputs now charge the same placeholder
  1e11-atom fee as legacy getFeeForType('Htlc'), shared via
  Client.HTLC_FEE_ATOMS.
- On-chain ids (token/order/delegation/pool) are validated (lowercase
  alphanumeric, 10-100 chars, bech32-like format) before they are
  interpolated into API paths or passed to encoders.
- Dead branch: forceSpendUtxo is always an array, so test
  forceSpendUtxo.length > 0 instead of truthiness.

Self-reviewed: all 19 suites + 76 tests green, tsc clean; empty
token_id wasm throw and get_token_id derivation verified empirically
against the real wasm build; pure extraction of input encoding keeps
getTransactionBINrepresentation behavior identical.
Reviewer finding #7 (pre-existing, now on the shared assembler path):
the fee loop mutated the first output's amount in place every
iteration, reading back the already-deducted value, so each extra
iteration compounded the deduction. Snapshot the original atoms before
the loop and always compute originalAtoms - totalFee per iteration.

Self-reviewed: all 19 suites + 76 tests green, tsc clean; the
delegation-withdraw snapshot is byte-identical (deduction result on the
converging path unchanged, compounding hazard removed).
…elper

Reviewer findings #8, #9, #10, #18:

- getFeeForCommand is now the single command->fee table; getFeeForType
  maps its legacy type vocabulary ('MintToken' etc.) onto it and
  getFeeForRawInput delegates, removing the duplicated wasm fee mapping.
- Nonce assignment is keyed per logical sequence: token ids as before,
  orders by 'order:<order_id>', delegations by 'delegation:<id>' — two
  fills of the same order in one transaction now get distinct
  sequential nonces.
- Explicit nonces are validated (non-negative integer, not below the
  next expected nonce for that sequence) and advance the counter so
  later inputs cannot collide with them.
- The block height constant used for fee estimation, token id
  derivation, order input encoding and witness signing is consolidated
  into a feeBlockHeight getter on Client (and Signer, which is a
  standalone class) replacing 7 scattered 200000n/'200000' literals.

Self-reviewed: all 19 suites + 76 tests green, tsc clean; all 17
snapshots byte-identical (consolidation is behavior-neutral); legacy
fee mapping verified case-by-case against the previous implementation.
- NPM_PUBLISH_SETUP.md: 'calendar a renewal reminder' -> 'and calendar
  a renewal reminder'.
- Export BuiltTransaction, a public alias for the shape returned by all
  build* methods (buildTransaction, buildRawTransaction, ...), so
  consumers and tests can type results without a local duplicate. Named
  distinctly because the exported name 'Transaction' is the
  TransactionBuilder class re-exported from './transaction'.

Self-reviewed: all 19 suites + 76 tests green, tsc clean; d.ts emits
the alias alongside the unexported interface.
…shape, nft id mismatch

Reviewer round-3 findings #1, #2, #4, #5 (correctness):

- atomsToDecimal('0', d) returned '0.' (dead || '0' fallback: the
  concatenation is always truthy), so the raw builder's strict decimal
  check rejected legitimate zero amounts. Trim trailing zeros and
  return '0' for an empty fraction.
- DelegationWithdraw fee deduction now renders the decimal via
  atomsToDecimal (exact, instead of float division by 1e11) and throws
  a clear error when the withdrawal amount is smaller than the
  transaction fee (netAtoms < 0).
- getRawTokenDetails validates the fetched token payload before caching
  and use: object shape, number_of_decimals a finite integer 0..18,
  authority a non-empty string; otherwise a descriptive 'not found /
  invalid data' error (previously an unchecked cast let garbage flow
  into decimal recomputation).
- An IssueNft output with a caller-supplied token_id that differs from
  the id derived from the transaction inputs now throws instead of
  being silently overwritten.

Self-reviewed: all 19 suites + 88 tests green, tsc clean; the
delegation-withdraw snapshot is byte-identical (the exact-decimal
formula matches the previous float division for the tested amount);
zero-amount Transfer verified to encode correctly in the real wasm
build; zero-amount acceptance test queued for the batched test commit
(test-file guard still intercepts this agent).
Reviewer round-3 finding #3: a manual run checked out arbitrary branch
state with no version binding (release.tag_name is empty on dispatch).
workflow_dispatch now requires a 'tag' input, the checkout ref is
release tag or input tag, and the version-assert step runs for BOTH
events (package.json version must equal the tag, leading 'v'
tolerated). Tag is passed via env indirection into the shell step.

Self-reviewed: YAML parse-checked; input interpolated via env, not
string substitution; all 19 suites + 88 tests green (unchanged).
Reviewer round-3 findings #6, #7, #8, #10, #12 (DRY):

- resolveRawTokenCommand collapses the 7x repeated token-command
  preamble in normalizeRawInputs (details fetch -> authority inference
  -> nonce assignment).
- normalizeRawLock / normalizeRawTimelock are now thin wrappers over
  one normalizeRawTimelockContent implementation (same dispatch, same
  validation, per-context field labels). Htlc UntilTime locks given as
  a plain timestamp scalar are now accepted alongside the {timestamp}
  form; both normalize to the identical canonical shape.
- validateRawCurrency is the single Coin/TokenV1 discriminant check,
  shared by value normalization and CreateOrder currency sides;
  the redundant normalizeRawCurrency wrapper is removed.
- The block height used across fee estimation, token id derivation,
  order encoding and signing is one exported module constant
  FEE_BLOCK_HEIGHT (replacing the duplicated Client/Signer getters);
  tests can import it instead of hardcoding 200000n.
- computeRawRequirements accumulates coin requirements through a
  single addCoinRequirement helper alongside addTokenOutput.

Self-reviewed: all 19 suites + 88 tests green, tsc clean; all 17
snapshots byte-identical (pure consolidation, behavior-neutral except
the documented Htlc scalar-timestamp leniency).
Reviewer round-3 finding #9: the nine hand-built
{hex: this.stringToHex(x), string: x} pairs in getRequiredInputsOutputs
(IssueFungibleToken metadata_uri/token_ticker and the seven IssueNft
metadata fields) now go through normalizeRawStringField, so legacy and
raw builders share one encoding/sanitization path. Plain-string params
wrap identically; field-level length caps and control-character
stripping now also apply to legacy issuance inputs.

Self-reviewed: all 19 suites + 88 tests green, tsc clean; issuance and
NFT snapshots byte-identical (ASCII inputs produce unchanged hex).
Reviewer round-3 finding #14: document that release tags must be
protected (GitHub tag protection rules / rulesets) so tags cannot be
mutated or deleted between release creation and the workflow run —
otherwise the workflow's tag<->package.json version binding has a
TOCTOU window.

Self-reviewed: all 19 suites + 88 tests green (docs-only change).
Reviewer round-3 finding #11: ids interpolated into API URL paths are
now validated (lowercase alphanumeric, 10-100 chars, bech32-like)
before every legacy fetch: buildTransfer, buildTransferNft (getNft),
the seven token-command builders, buildCreateOrder (ask/give token,
'Coin' bypassed), buildFillOrder, buildConcludeOrder,
buildBridgeRequest, buildDelegationStake (pool), buildDelegationWithdraw
(delegation + pool), buildCreateHtlc and buildBurn (non-'Coin').
Server-derived ids (order currency tokens, HTLC refund/spend token
lookups) are intentionally not re-validated. The raw builder's
order/delegation guards are unchanged; a misplaced duplicate from an
earlier staging mishap was removed.

staking.test.ts is included here rather than in the migration commit:
the 'wrong_pool_id' -> well-formed-unknown-pool fixture change is
interleaved with the file's api-mocks migration in the same hunks, and
a partial split would create an uncompilable intermediate state.
Malformed ids are now rejected before the fetch, so the fixture uses a
well-formed but unknown pool id to preserve the test's intent
(unknown pool -> 'Failed to fetch delegation id').

Self-reviewed: every id-fetch audited line by line (26 validateRawId
sites total); all 19 suites + 89 tests green, tsc clean, 17 snapshots
byte-identical.
…on and fee-height constant

Reviewer round-3 finding #13 (test batch from the tdd-guide pass):

- 12 legacy suites (client, htlc, issue-token, orders, signer,
  staking-create-delegation, token-commands, transfer, transfer-nft,
  transfer-token, utxo-change) drop their copy-pasted window.mojito
  stubs and inline fetch routers for tests/helpers/api-mocks.ts
  (setupApiMocks with per-suite tokens/utxos/chain-tip options),
  keeping only suite-specific routes (pools, delegations, orders).
- raw-transaction.test.ts: local AnyTx replaced by the exported
  BuiltTransaction type, createConnectedClient and
  expectRejectionWithoutProviderCalls helpers remove the repeated
  create/connect preamble and callsBefore bookkeeping, and fee
  expectations import FEE_BLOCK_HEIGHT instead of hardcoding 200000n.
- helpers/api-mocks.ts extended with the multi-token fixtures used by
  the migrated suites.

Self-reviewed: net -689 lines across suites; all 19 suites + 89 tests
green, tsc clean, all 17 snapshots byte-identical (no -u needed).
Reviewer round-5 findings #1-#5:

- buildRefundHtlc / buildSpendHtlc / extractHtlcSecret validate
  caller-supplied transaction ids (64-char hex, case-insensitive)
  before they flow into the /transaction/<id> API path; clear error,
  checked before any provider call. Existing fixtures already use
  realistic 64-hex ids.
- fetchRawTokenDetails also validates next_nonce: when present it must
  be a non-negative integer number (a string like '7' would corrupt
  nonce assignment via concatenation).
- validateRawCurrency returns fresh canonical {type:'Coin'} /
  {type:'TokenV1', token_id} objects so extra caller properties cannot
  leak into the CreateOrder transaction JSON.
- One shared validateTokenDecimals assertion (integer 0-18) now guards
  both raw issuance outputs and token-details lookup; raw issuance
  previously allowed unbounded decimals. (IssueNft outputs carry no
  decimals field, so there is nothing to validate on that path.)
- RawOutput Htlc refund_timelock JSDoc documents the accepted
  UntilTime forms (wrapped {timestamp} object or bare scalar; both
  normalize identically since the round-3 consolidation).

Self-reviewed: all 19 suites + 89 tests green, tsc clean; the
extractHtlcSecret fixture id is 64-hex so the new check does not
disturb it; validateRawCurrency change is behavior-neutral for
well-formed inputs (Canonicalization verified by unchanged snapshots).
… test helpers

- createConnectedClient and expectRejectionWithoutProviderCalls move
  from raw-transaction.test.ts into tests/helpers/api-mocks.ts so other
  suites can adopt them; setupApiMocks gains an 'addresses' option
  (defaults to account_01) used by the verify-challenge migration.
- New rejection coverage:
  - staking: DelegationWithdraw amount smaller than the fee throws
    ('DelegationWithdraw amount is smaller than the transaction fee');
    malformed pool_id rejected before any provider call.
  - htlc: malformed transaction_id rejected before any provider call
    (64-char hex check).
  - raw: token details payload with a string next_nonce rejected
    (would corrupt nonce arithmetic via concatenation); garbage token
    details payload rejected by the shared 0-18 decimals assertion;
    IssueNft output whose caller token_id differs from the derived one
    rejected instead of silently overwritten.

Self-reviewed: helper usage matches fetch behavior per case (only the
genuinely fetch-free rejections assert zero provider calls); all 19
suites + 95 tests green, tsc clean, 17 snapshots byte-identical.
…-mocks

Final two suites onto tests/helpers/api-mocks.ts, completing the
de-duplication of the inline window.mojito stub + fetch router pattern
across all suites:

- verify-challenge: custom address fixture now flows through the new
  setupApiMocks 'addresses' option; the unused malformed restore stub
  is dropped (autoRestore: false, restore never asserted).
- standalone-providers: local setupFetchMock replaced with
  setupApiMocks({ utxos: UTXOS }); these suites construct providers
  directly, so the mojito stub is unused but harmless.

Self-reviewed: 19/19 suites + 95 tests green, tsc clean, all 17
snapshots byte-identical; no fetchMock imports remain in the two
migrated files.
…sts, dedupe comment

Gate: pnpm test 19 suites / 95 tests green, 17 snapshots byte-identical; npx tsc --noEmit clean.
…unt bridge

Docs-only change; gate: full suite green (19 suites/95 tests), tsc clean.
Self-reviewed: all registry facts re-verified live (no npm org 'mintlayer',
no user 'mintlayer', maintainers owlsua/anyxem on sdk + wasm-lib, 1.0.39);
recommendations limited to npm/GitHub documented features (orgs, teams,
granular tokens, trusted publishing/OIDC, tag protection rules).
…A-pinned actions, staged publishing

- workflow_dispatch requires an explicit release tag (sdk-v*/react-v*/wasm-lib-v*)
- restore version-to-tag verification, generalized per package
- pin actions by commit SHA (checkout v7.0.1, setup-node v7.0.0, pnpm/action-setup v6.1.0)
- npm-publish environment gate + npm stage publish --provenance (trusted publishing OIDC)
- remove obsolete NPM_TOKEN bridge (superseded by org trusted publishers)
- remove docs/NPM_PUBLISH_SETUP.md (org migration completed; sensitive access details)
- enrich Transaction.fromHEX decode: map all input/output variants
  (was: UTXO-in/Transfer-out only, token_id dropped, ACCOUNT stub) —
  shapes pinned by wasm encode->decode probes
- move the assembly engine (UTXO selection, fee loop, encoding,
  IssueNft id derivation) into Transaction.assembleRaw; Client's
  buildRawTransaction/buildTransaction are thin facades now
- unify fee convergence + encode tail and UTXO selection across the
  fluent builder and the raw assembler (single FEE_AMOUNT_PER_KB)
- delete the low-level wasm re-export block (import from wasm-lib)
- drop dead fluent API surface (addAction no-op, unused addInput)
- unify duplicated canonical types on src/types/transaction
… engine

- byte-identical encode->decode->re-encode for all supported output types
  and account-command inputs (locks the decode mapper against drift)
- pin explorer-style decode shapes (token_id, {hex,string} pairs, command
  casing, nonce) and the Coin/token decimal conventions
- fix decoded UTXO index: use the real outpoint index, not array position
- fee parity: fluent build() and buildRawTransaction produce identical fees
Correctness (verified by reviewers against the real wasm):
- decoded UTXO inputs: UnmintTokens payload is a bare string (crash); use
  the real outpoint index, not the input's array position
- forceSpendUtxo entries: emit selector-shaped {input, utxo} and dedup
  against auto-selection (was: wrong shape crash + double-spend risk)
- DelegationWithdraw fee deduction: snapshot pre-loop amount (fee
  deduction compounded across convergence iterations) and stop mutating
  the caller's prepared outputs
- atomsToDecimal('0') returned '0.'; helpers single-sourced in utils.ts
- validate API-served order/delegation nonces before nonce arithmetic
- HTLC UTXOs excluded from auto-selection on BOTH paths (manual-spend)
- consistent block-height fallback (0/unset -> FEE_BLOCK_HEIGHT) across
  assembleRaw and the fluent builder

Dedupe/dead code:
- single assembly engine now also shares UTXO selection + fee/encode tail
- 26 dead wasm imports, dead type imports, duplicate helper sextet
  (mergeUint8Arrays/hexToUint8Array/atomsToDecimal/...) removed — utils.ts
  is the single home; SDK re-exports the public numeric helpers
- duplicate canonical type set on the Client replaced by
  src/types/transaction; AssembledTransaction = AssembledTransactionData
  + intent/htlc bridge fields; AssembleTransactionArgs collapsed into
  PreparedTransaction
- buildRefundHtlc/buildSpendHtlc collapsed into buildHtlcClaim
- removed: addAction no-op, unused addInput, client plumbing,
  stakingWithdraw/getTransactionId/setCurrentBlockHeight stubs, module
  load + build-args console logs

Quality:
- assembler errors now carry token id/amounts/fee diagnostics
- JSDoc accuracy pass (blockHeight param, fluent-vs-plain-data contract)
- prettier-formatted touched files
- assembleRaw: deep-clone the fee-deducted output (caller's prepared
  outputs no longer mutated through shared nested value/amount)
- withdraw + forced coin UTXOs: fee no longer subtracted from coin
  change as well (inputs - outputs === fee invariant restored)
- forced UTXO list self-deduped (caller-supplied duplicate outpoints)
- convergence-failure and insufficient-UTXO errors now carry diagnostics
- typed DelegationWithdraw path validates next_nonce like the raw path
- dead code: orphaned TotalSupply import, unreferenced Outpoint type,
  doubled JSDoc, dead @ts-ignore, stale setCurrentBlockHeight doc ref
- test: compounding regression now uses a nonzero base fee + asserts the
  caller's outputs stay unmutated; prettier-formatted
The undefined-guard filter from the round-1 decode fixes also dropped the
HTLC [spend_key, refund_key] arrays from estimate_transaction_size,
underpricing fees on every HTLC claim/spend. Flatten both keys again;
pinned by a size-estimate regression test (274 vs 171 bytes, fails at 68
on the regression).
@erubboli
erubboli force-pushed the feat/generic-tx-builder branch from d647f9c to 542aa47 Compare September 18, 2026 17:01
@erubboli
erubboli merged commit 33587a0 into main Sep 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants