Skip to content

PTB patterns ​

Programmable Transaction Blocks (PTBs) compose several Move calls into one atomic transaction. Every on-chain action in the Meddleware apps is a PTB built with @mysten/sui/transactions, most of them by the SDK builders (buildPurchaseTx, buildCreateGateTx, …); the patterns below are what those builders do.

Basic structure ​

ts
import { Transaction } from '@mysten/sui/transactions'
import { SuiGrpcClient } from '@mysten/sui/grpc'

const client = new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io:443' })
const tx = new Transaction()

// Add Move calls, object inputs and coin splits here
// ...

// Sign and execute with a keypair (scripts); in a browser app the wallet signs instead
const result = await client.signAndExecuteTransaction({
  transaction: tx,
  signer: keypair,
  include: { effects: true, events: true },
})
await client.waitForTransaction({ result }) // before reading its effects back

Common patterns ​

Calling a Move function ​

ts
tx.moveCall({
  target: `${PACKAGE_ID}::${MODULE}::${FUNCTION}`,
  arguments: [
    tx.object(objectId),            // an existing on-chain object (owned or shared)
    tx.pure.u64(1000n),             // a scalar
    tx.pure.address(recipientAddr),
    tx.pure.vector('u8', nonceBytes),
  ],
})

Splitting coins for a payment ​

access_gate::purchase takes the payment as a Coin<SUI>; split exactly the price from gas. The function mints the pass to the sender and refunds any overpayment itself, so nothing needs transferring afterwards:

ts
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(priceMist)])
tx.moveCall({
  target: `${ACCESS_GATE_PACKAGE}::access_gate::purchase`,
  arguments: [tx.object(GATE_ID), tx.object(PLATFORM_CONFIG_ID), payment],
})

Passing a result into a later call ​

A moveCall returns its results; pass them as arguments to later commands in the same PTB. Creating a gate builds its GatePolicy value first:

ts
const [policy] = tx.moveCall({
  target: `${ACCESS_GATE_PACKAGE}::access_gate::new_gate_policy`,
  arguments: [tx.pure.bool(true), tx.pure.bool(false), tx.pure.bool(true), tx.pure.bool(false)],
})
tx.moveCall({
  target: `${ACCESS_GATE_PACKAGE}::access_gate::create_gate`,
  arguments: [tx.object(PLATFORM_CONFIG_ID), tx.pure.u64(priceMist),
    /* recipient, uses, soulbound, auto-burn, name, image, description */ ...values, policy],
})

(See the access_gate API reference for every argument.)

Simulate before sending ​

Simulation runs the transaction without executing it — use it to validate inputs and size gas before asking a wallet to sign:

ts
tx.setSender(address)
const sim = await client.simulateTransaction({ transaction: tx, include: { effects: true } })
const outcome = sim.Transaction ?? sim.FailedTransaction
if (!outcome.status.success) {
  console.error('Would fail:', outcome.status.error?.message)
}

Gas budget ​

Wallets and signAndExecuteTransaction set the budget from a simulation automatically. Set one explicitly only for scripts that must cap spend:

ts
tx.setGasBudget(20_000_000n) // 0.02 SUI

Wallet integration (Vue) ​

Meddleware apps use @meddleware/wallet-adapter, a wallet-standard composable whose connection is shared by every view in the window:

ts
import type { Transaction } from '@mysten/sui/transactions'
import { useWallet } from '@meddleware/wallet-adapter'

const { account, buildExecutor } = useWallet({ requiredFeatures: ['sui:signTransaction'] })

async function run(tx: Transaction): Promise<string> {
  const exec = await buildExecutor('testnet', 'https://fullnode.testnet.sui.io:443')
  const { digest } = await exec.signAndExecute(tx)
  await exec.waitForTransaction(digest)
  return digest
}

Error handling ​

A transaction that executed but aborted comes back as FailedTransaction — it does not throw. Move aborts carry the module and the code; abort codes are only unique within a module (access_gate, nft_gate and timelock all use small integers), so always key on (module, code):

ts
const ABORTS: Record<string, Record<number, string>> = {
  access_gate: {
    1: 'This gate is paused.',
    2: 'Payment is below the gate price.',
    4: 'This pass has no uses left.',
    5: 'This pass or cap belongs to a different gate.',
    6: 'This gate is frozen.',
    10: 'This gate cannot be frozen while paused.',
    11: 'The price is below the platform minimum.',
    12: 'Pay the free-gate fee to make this gate free.',
  },
  nft_gate: { 2: 'Pass is for a different gate.', 3: 'Pass is used up.', 4: 'Gate is paused.' },
}

function describeFailure(outcome: { status: { success: boolean; error?: any } }): string | null {
  if (outcome.status.success) return null
  const err = outcome.status.error
  if (err?.$kind === 'MoveAbort') {
    const module = err.MoveAbort.location?.module ?? ''
    const code = Number(err.MoveAbort.abortCode)
    return ABORTS[module]?.[code] ?? `${module} aborted with code ${code}`
  }
  return err?.message ?? 'Transaction failed'
}

Full abort-code tables live in each package's API reference: access_gate · sealed_storage.

2026 Meddleware. All jams preserved.