Skip to main content
This guide walks through the client side of the Flex flow on Solana: create an escrow, fund it, register a session key, and use @faremeter/payment-solana/flex/client to pay for an HTTP resource. For a complete reference implementation including transaction confirmation, grace-period waits, and full teardown, see scripts/solana-example/flex-payment.ts in the faremeter monorepo.

Prerequisites

  • Node.js 18+
  • A Solana devnet keypair with some SOL for rent and transaction fees
  • Devnet USDC in the keypair’s token account (Circle faucet, select Solana devnet)
  • The address of a Flex-capable facilitator (the same key the merchant declares as facilitator in their requirements)
1

Install

pnpm add @faremeter/flex-solana @faremeter/payment-solana @faremeter/fetch @faremeter/info @solana/kit dotenv
pnpm add -D tsx
pnpm pkg set type=module
Set your wallet path and the facilitator address in .env:
.env
PAYER_KEYPAIR_PATH=$HOME/.config/solana/id.json
FACILITATOR_ADDRESS=<facilitator-public-key>
2

Set up the RPC, signer, and helpers

Create main.ts. The sendInstructions and confirmSignature helpers below are reused throughout the rest of the steps.
main.ts
import "dotenv/config";
import fs from "node:fs";
import {
  type Instruction,
  type KeyPairSigner,
  type Signature,
  address,
  appendTransactionMessageInstructions,
  createKeyPairSignerFromBytes,
  createSolanaRpc,
  createTransactionMessage,
  getBase64EncodedWireTransaction,
  pipe,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
} from "@solana/kit";
import { lookupKnownSPLToken } from "@faremeter/info/solana";

const network = "devnet";
const rpc = createSolanaRpc("https://api.devnet.solana.com");

const ownerBytes = JSON.parse(
  fs.readFileSync(process.env.PAYER_KEYPAIR_PATH!, "utf-8"),
) as number[];
const owner = await createKeyPairSignerFromBytes(Uint8Array.from(ownerBytes));

const facilitatorAddress = address(process.env.FACILITATOR_ADDRESS!);

const usdc = lookupKnownSPLToken(network, "USDC")!;
const mint = address(usdc.address);

async function confirmSignature(sig: Signature) {
  for (let i = 0; i < 60; i++) {
    const { value: statuses } = await rpc.getSignatureStatuses([sig]).send();
    const status = statuses[0];
    if (
      status?.confirmationStatus === "confirmed" ||
      status?.confirmationStatus === "finalized"
    ) {
      if (status.err) {
        throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
      }
      return;
    }
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error("Transaction confirmation timeout");
}

async function sendInstructions(
  feePayer: KeyPairSigner,
  instructions: Instruction[],
) {
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
  const msg = pipe(
    createTransactionMessage({ version: 0 }),
    (m) => setTransactionMessageFeePayerSigner(feePayer, m),
    (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
    (m) => appendTransactionMessageInstructions(instructions, m),
  );
  const signedTx = await signTransactionMessageWithSigners(msg);
  const wire = getBase64EncodedWireTransaction(signedTx);
  const sig = await rpc.sendTransaction(wire, { encoding: "base64" }).send();
  await confirmSignature(sig);
}
3

Create the escrow

main.ts
import {
  getCreateEscrowInstructionAsync,
  getDepositInstructionAsync,
} from "@faremeter/flex-solana";

const ESCROW_INDEX = Date.now();
const REFUND_TIMEOUT_SLOTS = 300; // ~2 minutes
const DEADMAN_TIMEOUT_SLOTS = 100_000; // ~11 hours
const MAX_SESSION_KEYS = 10;

const createIx = await getCreateEscrowInstructionAsync({
  owner,
  index: ESCROW_INDEX,
  facilitator: facilitatorAddress,
  refundTimeoutSlots: REFUND_TIMEOUT_SLOTS,
  deadmanTimeoutSlots: DEADMAN_TIMEOUT_SLOTS,
  maxSessionKeys: MAX_SESSION_KEYS,
});
await sendInstructions(owner, [createIx]);

const escrowAddress = createIx.accounts[1]!.address;
The escrow PDA is derived from [b"escrow", owner, index]. Pass a unique index per escrow you want to keep open with the same facilitator. Date.now() is used here for convenience — production code should track indices deliberately.
4

Deposit funds

main.ts
const { value: tokenAccounts } = await rpc
  .getTokenAccountsByOwner(owner.address, { mint }, { encoding: "base64" })
  .send();
const sourceTokenAccount = tokenAccounts[0]!.pubkey;

const depositIx = await getDepositInstructionAsync({
  depositor: owner,
  escrow: escrowAddress,
  mint,
  source: sourceTokenAccount,
  amount: 50_000n, // smallest unit (USDC is 6 decimals)
});
await sendInstructions(owner, [depositIx]);
The escrow’s vault PDA is created lazily on the first deposit per mint. An escrow can hold up to MAX_MINTS = 8 distinct token mints.
5

Register a session key

main.ts
import type { webcrypto } from "node:crypto";
import { getAddressFromPublicKey } from "@solana/kit";
import { getRegisterSessionKeyInstructionAsync } from "@faremeter/flex-solana";

const sessionKeyPair = (await crypto.subtle.generateKey("Ed25519", true, [
  "sign",
  "verify",
])) as webcrypto.CryptoKeyPair;

const sessionKeyAddress = await getAddressFromPublicKey(
  sessionKeyPair.publicKey,
);

const registerIx = await getRegisterSessionKeyInstructionAsync({
  owner,
  escrow: escrowAddress,
  sessionKey: sessionKeyAddress,
  expiresAtSlot: null, // never auto-expires
  revocationGracePeriodSlots: 100, // must be < refund_timeout_slots
});
await sendInstructions(owner, [registerIx]);
Save the sessionKeyPair somewhere durable. You’ll use it for every payment authorization until you revoke it. Treat it like any other private key — leaking it lets an attacker sign authorizations, though they still can’t drain funds without colluding with the facilitator.
6

Pay with Faremeter

The @faremeter/payment-solana/flex/client package exposes createPaymentHandler, which plugs into @faremeter/fetch’s wrapper. It signs Flex authorizations on demand whenever a server returns a matching 402 Payment Required response.
main.ts
import { createPaymentHandler } from "@faremeter/payment-solana/flex/client";
import { wrap } from "@faremeter/fetch";

const flexHandler = createPaymentHandler({
  network,
  escrow: escrowAddress,
  mint,
  sessionKeyPair,
  sessionKeyAddress,
  rpc,
});

const flexFetch = wrap(fetch, { handlers: [flexHandler] });

const res = await flexFetch("https://api.example.com/protected");
console.log(res.status, await res.json());
Run it:
pnpm tsx main.ts

What just happened

The handler:
  1. Inspects the server’s payment requirements and matches on (scheme, network, mint).
  2. Picks a maxAmount from the requirements and a fresh authorizationId.
  3. Builds a serialized authorization using serializePaymentAuthorization and signs it with the session key.
  4. Returns the signed payload; @faremeter/fetch retries the request with the PAYMENT-SIGNATURE header set.
The facilitator validates the authorization, holds the funds in memory, lets the request through, then settles the actual amount asynchronously. The client’s call returns once the response is ready — it does not wait for on-chain confirmation.

Cleanup (optional)

Tearing down state cleanly requires coordination with the facilitator (who must co-sign close_escrow). The end-to-end sequence is:
  1. Wait for in-flight settlements to land on-chain. Poll fetchEscrowAccount until pendingCount reflects everything you expect.
  2. Issue refunds for any pending settlements you don’t want finalized. This is a facilitator-signed instruction (getRefundInstruction); merchants typically trigger it via their own tooling.
  3. Revoke the session key, wait out the grace period, then close it (getRevokeSessionKeyInstructiongetCloseSessionKeyInstruction).
  4. Close the escrow. getCloseEscrowInstruction needs both the owner and the facilitator as TransactionSigners, plus mint_count * 2 writable remaining accounts (alternating vault PDA + owner-controlled destination per mint).
For a complete reference implementation including transaction confirmation and grace-period waits, see flex-payment.ts in the faremeter monorepo. If the facilitator has gone dark, the owner can recover funds without their cooperation. Once current_slot > last_activity_slot + deadman_timeout_slots, run void_pending for each open pending settlement (escrow owner is allowed to call it under deadman conditions), revoke and close every session key, then call emergency_close. emergency_close requires pending_count == 0 AND session_key_count == 0, so the cleanup order matters.

What’s next

  • Concepts — the protocol-level model behind these calls.
  • Facilitator — the operator side: hold management and settlement.
  • Program Reference — every on-chain instruction, account, and constraint.
  • API Reference — the full @faremeter/flex-solana surface.