Skip to content

TransactionBuilder pattern

The most useful single piece of the post-fv toolkit is the TransactionBuilder exposed by @gen3labs/futurepass-transact (and its identical @futureverse/transact predecessor). It chains four orthogonal concerns into one fluent expression:

  1. What to do — a domain call (mint an NFT, transfer a token, batch several calls).
  2. Who pays gas — the user’s EOA, or their FuturePass.
  3. What asset gas is paid in — XRP (default), or any other asset via FeeProxy.
  4. How to sign and watch — a single signAndSend with onSign / onSend lifecycle hooks.

This page is the conceptual reference. The code is in the per-package READMEs (gen3labs, futureverse).

The four canonical shapes

1. Domain call, EOA pays gas in XRP

The simplest path. Build, sign, send.

ts
const builder = await TransactionBuilder
  .nft(trnApi, signer, userSession.eoa, COLLECTION_ID)
  .mint({ quantity: 1, walletAddress: userSession.futurepass });

await builder.signAndSend({ onSign, onSend });

2. Domain call, EOA pays gas in a non-native asset (FeeProxy)

addFeeProxy({ assetId, slippage }) wraps the inner call in feeProxy.callWithFeePreferences, swapping the chosen asset for XRP at execution time.

ts
const builder = await TransactionBuilder
  .transfer({ destinationAddress: recipient, amount: 1 })
  .addFeeProxy({ assetId: ROOT_TOKEN_ID, slippage: 5 });

await builder.signAndSend({ onSign, onSend });

slippage is a percentage — guards against price moves between the time you sign and the time the fee swap executes.

3. Domain call, FuturePass pays gas

addFuturePass(futurepassAddress) wraps the inner call in futurepass.proxyExtrinsic. Useful when the user holds funds on the FuturePass account rather than the EOA.

ts
const builder = await TransactionBuilder
  .nft(trnApi, signer, userSession.eoa, COLLECTION_ID)
  .mint({ quantity: 1, walletAddress: userSession.futurepass })
  .addFuturePass(userSession.futurepass);

await builder.signAndSend({ onSign, onSend });

4. Batch several calls, FuturePass pays in a chosen asset

This is the everything-bagel: multiple calls, atomic batch, FuturePass proxy, FeeProxy on top.

ts
const tx1 = trnApi.tx.nft.mint(COLLECTION, 1, eoa);
const tx2 = trnApi.tx.nft.mint(COLLECTION, 5, fp);
const tx3 = trnApi.tx.assetsExt.transfer(1, fp, 1, true);
const tx4 = trnApi.tx.assetsExt.transfer(2, fp, 10, true);

const builder = await TransactionBuilder
  .batch(trnApi, signer, userSession.eoa)
  .addExtrinsic(tx1).addExtrinsic(tx2).addExtrinsic(tx3).addExtrinsic(tx4)
  .batchAll()
  .addFuturePassAndFeeProxy({
    futurePass: userSession.futurepass,
    assetId: ROOT_TOKEN_ID,
    slippage: 5,
  });

await builder.signAndSend({ onSign, onSend });

batchAll() produces an atomic batch (all-or-nothing); batch() is non-atomic. addFuturePassAndFeeProxy(...) is a convenience that does what addFuturePass(...) and addFeeProxy(...) would do together — the wrapping order matters and this helper picks the correct nesting.

Wrapping order (so you can debug it)

The four wrappers nest in this order:

   ┌─ futurepass.proxyExtrinsic ─────────────────────────┐
   │  ┌─ feeProxy.callWithFeePreferences ─────────────┐  │
   │  │  ┌─ utility.batchAll ──────────────────────┐  │  │
   │  │  │   nft.mint(...)                         │  │  │
   │  │  │   nft.mint(...)                         │  │  │
   │  │  │   assetsExt.transfer(...)               │  │  │
   │  │  │   assetsExt.transfer(...)               │  │  │
   │  │  └─────────────────────────────────────────┘  │  │
   │  └───────────────────────────────────────────────┘  │
   └─────────────────────────────────────────────────────┘

Reading inside-out: build the domain calls, batch them, swap an asset for XRP to pay fees, dispatch as the FuturePass.

This is also the order the chain itself decodes the call envelope when you read it out of system.events — knowing it makes debugging an extrinsic that lands "in block but failed" much faster.

Lifecycle hooks

signAndSend({ onSign, onSend }) returns a result describing the included extrinsic. Hooks fire as you'd expect:

HookWhen it firesWhat you typically do
onSignAfter the user’s wallet signs the message but before it’s submitted to the node.Show a "submitting…" spinner.
onSendAfter the node accepts the extrinsic into the pool.Update UI to "in flight".
(return value)After finalization.Read events, surface success / error to the user.

The return value carries the events emitted by the runtime — that’s where you read the nft.Mint / feeProxy.CallSwapped / futurepass.ProxyExecuted events that confirm each layer of the wrap actually fired.

Pre-flight checks (before users hit "Mint")

  • FuturePass balance for the fee asset. If the FuturePass is paying via FeeProxy, it needs both the asset and enough liquidity in the asset/XRP DEX pool — otherwise feeProxy.callWithFeePreferences aborts. Query dex.getAmountIn to estimate.
  • Slippage. A slippage of 5 (5%) is conservative; tight pools (small TVL on assetId/XRP) can move enough between sign and submit to busted a tighter setting.
  • Collection ownership for nft.mint. If you’re minting on a collection the EOA does not own and haven’t added as an authorised minter, the call fails — check nft.collectionInfo(collectionId) first.

When to skip the builder and go raw

The builder’s value is the wrapping-order discipline. You don’t need it when:

  • You're sending a single, vanilla extrinsic with EOA paying XRP — api.tx.<pallet>.<call>(...).signAndSend(signer) is two lines.
  • You're calling an EVM precompile from Solidity / viem — the wrapping concerns don't exist there.
Synthesised from the package READMEs of @gen3labs/futurepass-transact and @futureverse/transact; pallet-side behaviour cross-checked against the live runtime metadata under /api/root/.

Curated independently by Codeology. Source-attributed reference for The Root Network. Not affiliated with Futureverse / TRN Labs.