TypeScript SDK
src/lib/payflow.ts in the frontend repo wraps every contract function. It has no framework dependency and can be lifted into any TypeScript project.
Reading state
Reads run as a simulation. They cost nothing and submit nothing. The sourceAddress only has to be an account that exists.
import { payflow } from "@/lib/payflow";
const plan = await payflow.getPlan(address, 1);
// { id: 1n, merchant: "GB34...", amount: 10000000n, period: 60n, active: true }
const balance = await payflow.vaultBalance(address, address, tokenId);
// 30000000n
const due = await payflow.isDue(address, 1);
// true
Every numeric field comes back as bigint. Never convert it to number for arithmetic on money.
Writing state
Writes need a signer. signXdr takes an unsigned XDR string and returns a signed one — the wallet context supplies it.
const { address, signXdr } = useWallet();
const hash = await payflow.deposit(address, signXdr, tokenId, 50_000_000n);
const mandateId = await payflow.subscribe(address, signXdr, planId, 3);
await payflow.cancel(address, signXdr, mandateId);
writeContract builds the transaction, runs prepareTransaction to attach authorization entries and the resource footprint, hands it to the wallet, sends it, and polls until the network confirms. Skipping prepareTransaction produces a transaction the network rejects.
Encoding arguments
ScVal types are explicit. Guessing them is the most common source of silent failure.
import { arg } from "@/lib/payflow";
arg.address("GB34...") // Address
arg.u64(1) // u64 — plan and mandate ids
arg.u32(3) // u32 — max_charges, fee_bps
arg.i128(10_000_000n) // i128 — every amount
arg.bool(true) // bool
Calling a function with no wrapper
import { readContract, writeContract, arg } from "@/lib/payflow";
// Index getters are paged: start, limit, at most 50 ids per call.
const plans = await readContract<bigint[]>(
registryId,
"merchant_plans",
address,
[arg.address(merchant), arg.u32(0), arg.u32(50)],
);
const hash = await writeContract(
subscriptionId,
"charge",
address,
signXdr,
[arg.u64(mandateId)],
);
Errors
Raw failures look like Error(Contract, #6), which means nothing to a user. decodeContractError maps the code — scoped to the right contract — onto a sentence:
#6 on subscribe/charge -> "This subscription is not due yet."
#4 on deposit/withdraw -> "Not enough balance in your vault."
#4 on charge -> "The subscriber's vault does not hold enough..."
The last line is a cross-contract case: charge never raises its own #4, so a #4 there comes from the vault it calls.
When you add a contract error, add it to the ERRORS map too.
Amounts
Use the helpers in src/lib/format.ts. They are bigint-based throughout.
import { fromStroops, toStroops } from "@/lib/format";
fromStroops("9900000") // "0.99"
toStroops("2.5") // 25000000n
toStroops("2.55555555") // throws: at most 7 decimal places
toStroops rejects anything that is not a positive decimal. Do not replace it with parseFloat.