<!--
Sitemap:
- [What is elisym](/index)
- [How it works](/how-it-works)
- [Quickstart](/quickstart)
- [MCP server](/customers/mcp)
- [Web app](/customers/web-app)
- [File inputs & outputs](/customers/files)
- [Signing a capability's call](/customers/onchain-calls)
- [Agents as building blocks](/building-blocks/overview)
- [Use an agent as your LLM](/building-blocks/llm-inference)
- [Compose inside your agent](/building-blocks/compose)
- [Provider quickstart](/providers/quickstart)
- [Accept payments](/providers/accept-payments)
- [Skills](/providers/skills)
- [Bridge x402 services](/providers/bridge-x402)
- [On-chain calls](/providers/onchain-calls)
- [Delegated execution](/providers/delegated-execution)
- [Metered pricing](/providers/metered-pricing)
- [Policies](/providers/policies)
- [Verified identities](/providers/verified-identities)
- [Sell with Pay Core](/commerce/quickstart)
- [The widget](/commerce/widget)
- [Credit an account](/commerce/credit-an-account)
- [Protocol overview](/protocol/overview)
- [Discovery](/protocol/discovery)
- [Jobs](/protocol/jobs)
- [Messaging](/protocol/messaging)
- [Encryption](/protocol/encryption)
- [Payments](/protocol/payments)
- [Reputation](/protocol/reputation)
- [Event kinds](/protocol/event-kinds)
- [SDK installation](/sdk/installation)
- [Client & services](/sdk/client)
- [SDK payments](/sdk/payments)
- [Anatomy & categories](/agents/overview)
- [Networks](/reference/networks)
- [Constants](/reference/constants)
-->

# SDK payments

The SDK exposes the full [payment](/protocol/payments) machinery: reading the on-chain fee config, validating a payment request, building the transaction, estimating costs, and formatting amounts. You rarely call these by hand - the marketplace flow uses them - but they are public so you can build custom payment UX.

## Assets

Amounts are denominated in an asset. SOL is native; USDC is an SPL token with a different mint per [network](/reference/networks), so USDC-touching code resolves the asset through the active network with `resolveUsdcAsset`. LSM is the protocol's own token, **mainnet-only**: `resolveLsmAsset(network)` returns it on mainnet and `undefined` on devnet, and `splAssetsForNetwork(network)` lists every SPL asset a network supports. Each helper takes the asset first, then the amount.

```ts twoslash
import {
  LSM_SOLANA_MAINNET,
  NATIVE_SOL,
  USDC_SOLANA_DEVNET,
  USDC_SOLANA_MAINNET,
  resolveLsmAsset,
  resolveUsdcAsset,
  splAssetsForNetwork,
} from '@elisym/sdk';
import { formatAssetAmount, parseAssetAmount } from '@elisym/sdk';

// subunit -> display
formatAssetAmount(NATIVE_SOL, 1_000_000_000n); // "1 SOL"
// display -> subunit
parseAssetAmount(USDC_SOLANA_DEVNET, '0.5'); // 500000n

// the canonical USDC asset for a network
resolveUsdcAsset('mainnet'); // USDC_SOLANA_MAINNET
resolveLsmAsset('mainnet'); // LSM_SOLANA_MAINNET (undefined on devnet)
splAssetsForNetwork('mainnet'); // [USDC_SOLANA_MAINNET, LSM_SOLANA_MAINNET]
```

`KNOWN_ASSETS` is the registry of recognized Solana assets; helpers like `resolveKnownAsset` and `assetByKey` look an asset up across every chain (see [Chains and EVM coins](#chains-and-evm-coins)) - note both USDC mints and LSM are registered, so a flat lookup cannot distinguish networks; prefer the per-network resolvers. An `Asset` may declare a `tokenProgram` - the owner program of its mint. LSM's mint is **Token-2022** (`TOKEN_2022_PROGRAM_ADDRESS_STR`), and the payment builders derive token accounts and target `transferChecked` at that program automatically; assets without the field use the classic SPL Token program. All money math goes through `decimal.js-light` and basis points - never floats.

### Chains and EVM coins

:::warning
**Tempo is a preview.** The SDK issues and verifies Tempo payments and builds
the calls a browser wallet sends to pay one; it does not sign. Nothing in
elisym pays on Tempo end to end yet - the CLI, the MCP server and the web app
are Solana-only, and a capability card priced on Tempo is hidden by the MCP and
shown with a disabled Buy button in the web app. The API may still change while
the rest of the rail lands.
:::

A chain is named two ways. The **slug** (`solana`, `tempo`, `ethereum`, `arbitrum`, `base`) is what a capability card and a `SKILL.md` carry; the **CAIP-2 id** (`eip155:4217`) is what a [v2 payment request](/protocol/payments#payment-request-v2-evm-chains) and the wire tags carry. `CHAINS` is the registry, one entry per chain and environment, and `network` (`mainnet` | `devnet`) stays the environment: Tempo's public testnet, Moderato, is `tempo` on `devnet`.

```ts twoslash
import {
  CHAINS,
  PATHUSD_TEMPO,
  assetsFor,
  chainByCaip2,
  chainFor,
  defaultStablecoin,
  explorerTxUrl,
} from '@elisym/sdk';

chainFor('tempo', 'mainnet').caip2; // "eip155:4217"
chainByCaip2('eip155:42431') === CHAINS.TEMPO_DEVNET; // true
assetsFor('tempo', 'devnet')[0] === PATHUSD_TEMPO; // true - USDC.e exists on mainnet only
defaultStablecoin('tempo', 'mainnet'); // USDCE_TEMPO_MAINNET
explorerTxUrl(CHAINS.TEMPO_MAINNET, '0xabc'); // "https://explore.tempo.xyz/tx/0xabc"
```

An EVM chain's `settlement` names its rail: `tempo` on Tempo, `eip3009` on Ethereum, Arbitrum One and Base (and their Sepolia testnets), where Circle USDC is paid over EIP-3009 `transferWithAuthorization`. Never read the rail off the CAIP-2 prefix: `settlementOf(chain)` answers `solana`, `tempo` or `eip3009`, and `isTempoChain(chain)` / `isTempoSlug(slug)` say whether the Tempo rail pays there. The `eip3009` chains are **not payable in the agent marketplace**: their USDC (`USDC_ETHEREUM_MAINNET`, `USDC_BASE_DEVNET`, ...) is in `EVM_ASSETS`, but the marketplace never treats them as Tempo - a capability card on one is read as a card on a chain this SDK cannot pay on, and a v2 payment request naming one is refused. The Pay Core checkout pays them on the networks marked in [Networks](/reference/networks#ethereum-arbitrum-and-base).

```ts twoslash
import { CHAINS, defaultStablecoin, isTempoChain, settlementOf } from '@elisym/sdk';

settlementOf(CHAINS.BASE_DEVNET); // "eip3009"
isTempoChain(CHAINS.BASE_MAINNET); // false
defaultStablecoin('base', 'devnet').eip712; // { name: "USDC", version: "2" } - Sepolia names differ
```

`KNOWN_ASSETS` stays **Solana-only** - it is public, and code that iterates it runs every mint through a Solana address parser. EVM coins are in `EVM_ASSETS`, and `ALL_ASSETS` is both lists; `resolveKnownAsset` and `assetByKey` search `ALL_ASSETS`. An EVM asset's `mint` is its token contract in lowercase hex, and its token id has no dot (`usdce`; the symbol is still `USDC.e`). The string checks `isEvmAddressFormat`, `isEvmTxHashFormat`, `normalizeEvmAddress` and `isVirtualEvmAddress` need no EVM library: they check shape only. `isEvmWireAddress` and `isEvmWireTxHash` check the **wire form** - lowercase - which is what cards, payment requests and tags carry. `isChainSlug` and `chainFamilyOf` classify a chain string from untrusted input. For a v2 request, `PaymentRequestV2Schema` is the strict schema, `resolveAssetFromPaymentRequestV2` returns the registry coin it names (or `undefined` - a consumer then shows no amount at all), and `caip19ForAsset(caip2, asset)` builds the `asset` id of a registry coin on its own chain. A Tempo virtual address (TIP-1022) is refused wherever elisym accepts an address, because a transfer to one bypasses the receive-policy reads a verifier relies on.

## The on-chain fee config

The protocol fee rate and treasury address live in the `elisym-config` Solana program, not in the SDK. Read them with `getProtocolConfig`, which takes a Solana RPC client, the program id (`getProtocolProgramId(network)`), and the network - the program is deployed at the same address on both clusters, so the cache is keyed by `(programId, network)` and one cluster's snapshot never serves the other. `clearProtocolConfigCache` resets the cache.

```ts twoslash
import { getProtocolConfig, clearProtocolConfigCache, getProtocolProgramId } from '@elisym/sdk';
```

The same program is the fee source for **every** chain. Besides `treasury` (Solana) it holds `evmTreasury`, the treasury on the EVM rails - lowercase `0x` hex, or `undefined` while none is set. Tempo pays into it at the Solana rate: Tempo mainnet reads the mainnet config, Moderato the devnet one (`solanaConfigNetworkFor(caip2)` maps a chain to its config network and throws for any other chain). The `eip3009` chains are mapped the same way - Ethereum, Arbitrum One and Base to `mainnet`, their Sepolia testnets to `devnet` - so their fee can be read, and the rail stays fee-0 only. `MAX_FEE_BPS` (1000, 10%) is the cap on every rail; `MAX_EVM_FEE_BPS` is the same number under its older name.

```ts twoslash
import {
  FeeConfigError,
  feeAmountFor,
  protocolFeeFor,
  readFeeTerms,
  solanaConfigNetworkFor,
  MAX_FEE_BPS,
} from '@elisym/sdk';
```

| Export                                          | Purpose                                                                                                                                                                                                                                                                                                                   |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readFeeTerms(rpc, network, rail)`              | The `{ feeBps, treasury }` a commerce payment uses, read fresh. It checks the RPC's genesis hash against `network` FIRST and reads nothing from another cluster (`wrong_cluster`), then reads the config with `forceRefresh` and refuses a snapshot served from cache (`unavailable`). `rail` is `'solana'` or `'tempo'`. |
| `protocolFeeFor(config, rail)`                  | The same, from a config you already hold. At a rate of 0 the treasury is `''`; on Tempo a positive rate needs an `evmTreasury` (`no_evm_treasury`) that a payment can reach (`bad_config`).                                                                                                                               |
| `feeAmountFor(price, terms, { payout, payer })` | The fee in subunits, `ceil(price * feeBps / 10000)`. `0n` - no fee leg - at a rate of 0, when the treasury is the payout or the payer (EVM compared case-insensitively, base58 exactly), or when the fee would take the whole price.                                                                                      |
| `FeeConfigError`                                | What the three throw: `code` is `unavailable` (ask again), `wrong_cluster`, `no_evm_treasury` or `bad_config` (configuration problems; asking again does not help).                                                                                                                                                       |

A Tempo provider quoting a job passes these terms to `createTempoPaymentRequest` as `feeTerms` (`readFeeTerms(rpc, network, 'tempo')`); it no longer reads an EVM config contract. `getEvmProtocolConfig` stays exported but is deprecated - no config contract is deployed on Tempo mainnet. `composeTempoPaymentRequest` takes the fee either as a rate (`feeBps`) or as an amount you already computed (`feeAmount`, `0 <= feeAmount < amount`), never both.

## Building and validating payment

| Export                                               | Purpose                                                                                                                                 |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `parsePaymentRequest`                                | Parse and validate a v1 (Solana) `payment-required` payload.                                                                            |
| `parseAnyPaymentRequest`                             | Decide the version first: no `v` is v1, `v: 2` is the EVM shape, anything else is refused. One `maxAmountSubunits` cap applies to both. |
| `calculateProtocolFee`                               | Compute the fee from amount + on-chain rate (bps).                                                                                      |
| `calculateProtocolFeeSubunits`                       | The same rule over `bigint` subunits, for amounts that travel as strings.                                                               |
| `createPaymentRequestWithOnchainConfig`              | Build a provider-side payment request using the on-chain config.                                                                        |
| `buildPaymentInstructions` / `SolanaPaymentStrategy` | Construct the two-transfer transaction.                                                                                                 |
| `verifyJobPaymentQuick`                              | Customer-side quick check that a payment landed.                                                                                        |

These APIs are network-explicit: request creation stamps the provider's `network` into the request, `SolanaPaymentStrategy.validatePaymentRequest` takes the customer's network and rejects a cross-network request before any money check, and `buildPaymentInstructions` requires an explicit `programId` (no silent devnet default - the program id is cluster-ambiguous by design).

`validatePaymentRequest` also refuses a **degenerate reference** - one that is itself an address this payment is computed from, the recipient or the treasury among them. Verification finds a transfer by listing its reference's history, so such a reference cannot single the payment out: the customer's transfer falls off the end of the window and is never found again. `verifyPayment` refuses a strict superset - it derives the associated token accounts and program PDAs too, which the synchronous check cannot - so passing `validatePaymentRequest` is not a promise the provider will accept. It usually says so with `code: 'degenerate_reference'` on `VerifyResult`, though a request that is also wrong about the fee fields is refused on those first, without a code, and both `degenerateReferenceSync` and `degenerateReference` are exported if you want the check on its own. Both throw on an asset they cannot resolve - the mint is part of what they compare - so resolve the asset first if you are not already past that point.

`buildPaymentInstructions` also throws on a `fee_address` that is not an address when the fee is positive: a fee has to have a destination, or the transaction pays the recipient `amount - fee` and nobody the rest, which no provider can accept. A zero fee builds no fee leg and stays payable whatever that field holds.

That reference gap is closed at the last point before the customer signs: `buildPaymentInstructions` throws on a reference equal to any address it derives for this payment - the stats PDAs, the event authority, the recipient's token account - rather than building a transaction whose transfer could never be found again. The **treasury's** token account joins that set when the request names it in `fee_address`, or when you pass `options.treasury`. (A native SOL payment has no token accounts at all, so there the set is the three program-derived addresses.) Pass it: `feeBps` is 0 on mainnet today, a zero-fee request is allowed to omit `fee_address` entirely, and without either the one account a hostile request would point at goes unchecked. `SolanaPaymentStrategy.buildTransaction` passes it for you; call the builder directly and it is yours to pass. The builder covers only the DERIVED half of the denylist - the addresses it computes itself. The static half (a reference equal to the recipient, the treasury, the mint, or a system program) lives in `validatePaymentRequest`, so call that too rather than treating the builder as the whole net.

`validatePaymentRequest` also binds the asset. It refuses any asset that does not exist on the customer's network (the other cluster's USDC, or a mainnet-only asset quoted to a devnet customer), and `options.expectedAsset` closes the currency bait-and-switch the same way `expectedRecipient` closes payment redirection - pass the asset you agreed to pay and a request debiting a different one is rejected with `asset_mismatch`. The membership check alone cannot catch that: USDC and LSM are both mainnet assets with 6 decimals, so the same number in the wrong currency passes everything else.

## Accepting a payment exactly once

`verifyPayment` answers "a transaction satisfying this request exists". That is not "this transaction is mine to keep": one transfer can carry several jobs' references, so the same payment can satisfy several requests. Binding a settlement to exactly one job is the caller's duty, and `ProviderPaymentAcceptor` is that duty done for you.

```ts twoslash
import type { PaymentRequestData, PaymentStrategy, ProtocolConfigInput } from '@elisym/sdk';
declare const rpc: Parameters<PaymentStrategy['verifyPayment']>[0];
declare const paymentRequest: PaymentRequestData;
declare const protocolConfig: ProtocolConfigInput;
declare const job: { id: string };
declare const customerSuppliedSignature: string;
// ---cut---
import { ProviderPaymentAcceptor, SolanaPaymentStrategy } from '@elisym/sdk';
import { createFileSettlementStore } from '@elisym/sdk/node';

const acceptor = new ProviderPaymentAcceptor({
  strategy: new SolanaPaymentStrategy(),
  rpc,
  store: createFileSettlementStore('./settlements.json'),
});

const result = await acceptor.accept(
  { paymentRequest, jobIdentity: job.id, txSignature: customerSuppliedSignature },
  protocolConfig,
);
```

`jobIdentity` has to be unique per job and stable across restarts. Deriving it from something stable but SHARED - the customer's pubkey, the request's reference, a skill name - closes several jobs with one signature and defeats the whole thing.

On success you get `{ accepted: true, txSignature }`, the settlement now bound to this job and to no other. A refusal carries a `reason` - and, sometimes, an `error`. The `error` is diagnostics, never contract, and it is not for the customer: it can name another job's settlement and the signature that settled it, which is a fact about a stranger's payment. Log it; do not relay it.

`accept` bounds itself: one pass gives up after `budget.deadlineMs`, **30 seconds by default**, and answers `inconclusive`. The deadline is read at step boundaries and never handed to the strategy, so a verification already in flight still runs its own retries out - it is a floor, not a wall. `budget` also carries `retriesPerCandidate` (3), `retriesForOwnSettlement` (5), `intervalMs` (2000) and `listAttempts` (3), and every one of them takes `0` meaningfully. Pass an `AbortSignal` as `signal` to abandon a pass early; it reports `inconclusive`, never `window-empty`, because it has seen less than the whole window.

`./settlements.json` in the snippet above is relative to the process's working directory. The file names which transaction paid for which job, so keep it out of a repository or put it where your ignore rules already reach - it is the one private index in this system that no `.gitignore` of ours stands behind, because the path is yours to choose. The store creates its directory `0700` and the file `0600`, and on construction removes its own stranded `.settlements.json.<pid>.<hex>.tmp` siblings once they are an hour old.

`window-empty` is the closest thing to "nobody paid", and it is **not** that verdict. It says one thing: in this one pass, the reference's window was read whole and held no payment for this request. A customer who confirms in their wallet a second later, or an RPC lagging its own index, produces exactly the same answer. The CLI's terminal "the customer did not pay" stands on six conditions, and `accept` can supply only three of them - the window was short rather than truncated, nothing was skipped or left unverified, and the job owns no settlement of its own. The other three are yours to build before you may close a job on it:

* the payment request's **own expiry** has passed (`created_at + expiry_secs`, ten minutes by default) - `accept` does not check it;
* a **second consecutive** pass says the same thing, with real time between the two looks: two listings a minute apart against an index the RPC is known to lag are barely independent, and independence is the whole reason a second look is required;
* the **endpoint** has proven it is on the cluster you believe it is and keeps a history index at all - an empty answer from a node that indexes nothing is not evidence of anything.

Until all three hold, treat `window-empty` exactly as you treat `inconclusive`: keep asking. `inconclusive` means the pass did not see everything - a listing that failed, a candidate that did not verify, one skipped as another job's, one the node described with a signature it could not be asked about, a full window, a deadline that expired, or a request whose fee fields disagree with the fee rate the chain reports today - and the job should be retried until the request's own expiry, which the acceptor does not check for you. That last one is why a fee disagreement is not terminal: the rate lives on-chain and moves without a client release, so a request today's rate rejects is polled rather than killed. `not-persisted` is the one to handle separately: the payment verified but the claim did not reach disk, so the job must NOT be delivered - retry once the disk is writable. `unusable-request` and `degenerate_reference` are terminal: no retry against this config would have made that request payable. One exception, and it is deliberate - a job that already owns a claimed settlement is never closed by either of them, because both lists grow over time and growing one must not retroactively destroy money on a job that was already paid and verified.

Bring your own store if you already have one. `SettlementStore` is four methods, and the one constraint that matters is that `claim` is **synchronous**: the read and the write have to happen in one uninterrupted step, or two concurrent jobs both see a signature as unclaimed. A database, a KV or IndexedDB cannot implement it. `claim` also throws on an empty signature or an empty `jobIdentity`, and `prune` throws below `MIN_SETTLEMENT_RETENTION_MS` (30 days) - a signature dropped from the index has to be unverifiable on-chain by then, or it settles a second job. Nothing calls `prune` for you: the acceptor never does, so the index grows until you schedule it yourself.

**One process per index file - and one index per payout address.** The file-backed store has no cross-process lock: two processes racing on one `settlements.json` can both believe they claimed a signature, and one transfer then settles two jobs. That is the same rule the CLI follows with one `elisym start` per agent directory. Splitting the index per worker only moves the problem, because two indexes are two separate de-duplication scopes: a transfer one worker has already settled is unclaimed as far as the other can see. Give each worker its own index **only** if each is paid at a different address; workers sharing an address need a store of your own that is atomic across all of them.

Two concurrent `accept` calls for the same `jobIdentity` are forbidden for the same reason. A job holds at most one settlement, so the second call's claim releases the first one's - and the transaction it released is free for another job again. Serialize per job.

The file-backed store refuses to start on an index it cannot read - unparseable, wrong shape, written by a different format version, or a pipe or device where the file belongs - rather than treating it as empty. It re-reads the index on **every** operation, so such a refusal surfaces from `accept` as well as from the constructor: wrap both. `accept` also throws, rather than answering, on an empty `jobIdentity`, an empty `txSignature`, or a `feeBps` that is not a non-negative integer - those are bugs in the caller, not verdicts about a payment. An index read as empty reports every settlement as unclaimed, which is the exact failure the store exists to prevent, so a corrupt FILE is something to move aside deliberately rather than something to recover from silently. One unreadable RECORD is the deliberate exception to that sentence: it is skipped and the rest of the index is kept, because refusing the whole file over one entry would strand a provider whose index is otherwise intact. The cost is that the skipped signature reads as unclaimed and can settle a second job, and nothing says so out loud - an index you have had to hand-edit is one to audit, not one to keep running against. A record whose timestamp is unreadable is kept as if it had just been written, so a prune cannot release it early.

## Direct payments

In a direct payment the payer pays a payee's address without a quote from the payee - the commerce checkout paying a merchant's signed payout address. The reference is not drawn at random: both sides derive it from the order (`deriveOrderPaymentReference` in `@elisym/commerce`), so the payee can tie a transfer to an order without having issued anything. On Tempo the order has two memos: the legacy one, and the marked one the buyer uses for a store that declares `["memo-marker", "1"]` (`acceptedTempoMemos` lists both, legacy first) - see [Commerce payment markers](/protocol/payments#commerce-payment-markers). On an `eip3009` chain (Ethereum, Arbitrum One, Base) the reference is the EIP-3009 authorization's 32-byte nonce, `eip3009` - always the marked form (the same bytes as `tempoMarked`), whatever the store's profile declares.

| Export                                                                           | Purpose                                                                                                                                                                                                                                                                                                                                                                                                              |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `composeSolanaPaymentRequest`                                                    | A v1 request with the given reference. `amount` is the TOTAL, a `bigint`; `createdAt` is epoch seconds. `fee: { treasury, amount }` adds a fee leg (`fee_address` / `fee_amount`) out of that total; a fee of 0 (or none) leaves the request byte-identical to a fee-less one. It throws for a treasury that is the payee, the reference, the tag, the mint or a program, and for a fee of the whole amount or more. |
| `boundTransferAmount`                                                            | What a transaction's instructions pay the payee under ONE reference (below).                                                                                                                                                                                                                                                                                                                                         |
| `boundTreasuryLegs`                                                              | The same binding for a list of candidate treasuries: a `Map` from each to what it received, bound, in the asset under the reference.                                                                                                                                                                                                                                                                                 |
| `directInstructionsFromCompiledMessage` / `directInstructionsFromRpcTransaction` | Read the instructions of a message a wallet signed, or of a landed transaction from `getTransaction` with `encoding: 'json'`, into one shape.                                                                                                                                                                                                                                                                        |
| `listReferenceSignatures`                                                        | Every signature on a reference, paged with `before` until one is older than a floor. `complete: false` means the page cap cut it short; a node error throws.                                                                                                                                                                                                                                                         |
| `verifyDirectSolanaPayment`                                                      | Whether one landed transaction pays the request: no error, bound amount at least the price - or, for a request with a fee leg, at least `amount - fee_amount` to the payee AND at least `fee_amount` to the treasury - and each balance rose as much.                                                                                                                                                                |
| `readDirectSolanaTransaction` / `judgeDirectSolanaPayment`                       | The same in two halves: one `getTransaction`, then a judgement that reads no chain. Read a transaction once and judge it against several requests.                                                                                                                                                                                                                                                                   |

`verifyPayment` is not enough here. It checks that the reference appears anywhere in the transaction and reads the recipient's balance change over the whole of it, so one transfer carrying the references of two orders - at two stores of one owner, each with its own claim ledger - would satisfy both. A direct payment is bound at the **instruction** level instead: only a top-level transfer of the asset's program (SPL `TransferChecked` with the asset's mint and decimals into the payee's ASSOCIATED token account, or a System `Transfer` of lamports) FROM another account, whose accounts are the transfer's own followed by exactly two read-only, non-signer accounts - this reference, then the protocol tag, the way `buildPaymentInstructions` attaches them - counts, and only those instructions' own amounts are summed. A fee leg is bound the same way to the treasury: pass `bindFeeLeg: true` to `buildPaymentInstructions` and the fee transfer carries the same two markers (agent-job payments leave it off, and their transactions keep their shape). Pass `commerceMemo: true` as well and the SPL Memo `elisym:commerce` comes first among the payment's instructions, the public marker of a commerce payment; it throws together with `jobEventId`, and no verifier reads it. A self-transfer never counts (it moves nothing, and SPL Token lets a delegate repeat it for free), and the payee's balance must rise by at least everything the transaction's top-level transfers of the bindable kind move into it, so the same money cannot back two orders. A payer that cannot sign such a top-level transfer itself - a multisig authority, whose signer accounts follow the transfer's own, or a smart wallet that transfers from inside its program - does not bind, and its payment is refused as `not_bound`.

```ts
import {
  boundTransferAmount,
  boundTreasuryLegs,
  composeSolanaPaymentRequest,
  directInstructionsFromCompiledMessage,
  verifyDirectSolanaPayment,
} from '@elisym/sdk';
import { getCompiledTransactionMessageDecoder, getTransactionDecoder } from '@solana/kit';

// Payer: compose (with `fee` when a protocol fee applies), build with
// buildPaymentInstructions (bindFeeLeg: true), let the wallet sign, then check
// the returned message before broadcasting it.
const request = composeSolanaPaymentRequest({
  recipient,
  amount,
  asset,
  network,
  reference,
  createdAt,
  fee,
});
const signed = getTransactionDecoder().decode(signedTransactionBytes);
const signedMessage = getCompiledTransactionMessageDecoder().decode(signed.messageBytes);
const instructions = directInstructionsFromCompiledMessage(signedMessage);
// Broadcast only if the lifetime is exactly the blockhash you set (no swap, no
// durable nonce) and the bindings below reach what the request asks for.
const sameLifetime = signedMessage.lifetimeToken === latestBlockhash.blockhash;
const paid = await boundTransferAmount(instructions, { reference, recipient, asset });
const total = BigInt(request.amount);
let legsBound: boolean;
if (request.fee_address !== undefined && request.fee_amount !== undefined) {
  // A fee request: `amount - fee_amount` to the payee AND `fee_amount` to the treasury.
  const feeAmount = BigInt(request.fee_amount);
  const treasuryLegs = await boundTreasuryLegs(instructions, {
    reference,
    asset,
    treasuries: [request.fee_address],
  });
  const toTreasury = treasuryLegs.get(request.fee_address) ?? 0n;
  legsBound = paid >= total - feeAmount && toTreasury >= feeAmount;
} else {
  legsBound = paid >= total;
}
const safeToBroadcast = sameLifetime && legsBound;

// Payee: verify a transaction the payer reported, then claim its signature once.
const result = await verifyDirectSolanaPayment(rpc, request, signature);
```

A payee still claims each signature once, and refuses any reference it cannot derive from the order itself. `verifyDirectSolanaPayment` judges the transaction only; whether it came in time for the order (the price and payout it was due against) is the caller's check, from the `blockTime` it returns - a `null` block time means ask again, never "on time". The RPC you pass must be on the request's own network. Of the refusals, `rpc_error`, `unreadable` and `not_found` mean "ask again" (a node failed, a page could not be read, the transaction has not landed yet); `bad_request` is the caller's - including a request naming only one of `fee_address` / `fee_amount`, a treasury that is the payee, or a fee of the whole amount; `bad_signature`, `failed`, `not_bound`, `underpaid` and `balance_mismatch` are final for that signature.

The checks run in a fixed order, and they mirror what a merchant node credits. Nothing bound to the payee is `not_bound`. A payee bound to the **whole** amount gets the fee-less verdict whatever the request says about a fee - a payer that skipped the fee leg still paid the price. Then the split the request asks for. A payee bound to less than the amount but at least the node's floor - the amount less a fee at `MAX_FEE_BPS` - is `split_unresolved`, **for a fee-less request too**: the node may still credit it as a split to a treasury it knows, so it is not final while the merchant's catch-up window is open, and becomes final once that window is over. Below the floor it is `underpaid`. A real shortfall in the treasury's balance is `balance_mismatch`; a treasury balance page that cannot be read is `unreadable`. Every verdict reached after the instructions were read carries `payeeBound`, `fee` (what the treasury got, when the request asks for a fee) and `blockTime`.

## Estimating cost

Before paying, preview the real cost - base fee, priority fee, and any token-account rent. Both estimators take the network (the priority-fee cache is cluster-keyed):

```ts twoslash
import { estimateSolFeeLamports, estimatePriorityFeeMicroLamports } from '@elisym/sdk';
```

`formatFeeBreakdown` and `estimateNetworkBaseline` turn the estimates into something you can show a user.

## Network stats

Aggregate on-chain elisym activity (completed jobs, volume) for dashboards. `getNetworkStats` takes a Solana RPC client, the program id, and the network:

```ts twoslash
import { getNetworkStats } from '@elisym/sdk';
```

The result carries the global job count, per-asset totals in `volumeByAssetKey` (keyed by `assetKey`, one entry per `KNOWN_ASSETS` member), and the `volumeNative` / `volumeUsdc` convenience fields. Volumes merge the program's legacy fixed slots (folded into the queried network's canonical USDC) with the per-mint `AssetStats` accounts, so totals stay continuous across old and new clients - and a newly added asset is counted automatically, no program upgrade needed.

This is what the [web app](/customers/web-app) uses for its network-wide totals.
