What Is An Adapter?
An adapter is the bridge between your Solana program and Riptide's simulation engine. It's a single TOML file that answers a handful of questions about your program:
- What is it? The compiled program bytes and its IDL.
- What state does it hold? The accounts the program uses — which are shared across all actors, and which belong to individual users.
- What can be done to it? The instructions, mapped to logical actions like
depositorliquidatethat personas can call during a run. - What does it observe? Account fields and oracle bindings exposed to personas, scenarios, and invariants.
- What should stay true? The invariants — machine-checkable properties like the pool should always be fully backed or bad debt should never exist.
The adapter carries the behavior policies and structural mapping; scenarios choose which personas run, for how long, and across which seeds. Together they give Riptide enough structure to drive your program deterministically and to know what a failure looks like at the field level.
Why it's a TOML file
Everything Riptide reads needs to be committed and diff-able. A TOML file in your repo stays reviewable, versioned, and pointable from CI. There's no runtime IDL fetch, no hosted config — if the adapter runs on your machine today, it should run the same way in six months on a clean runner.
Trust boundary: every runtime claim should be traceable to a committed file. Riptide doesn't fetch anything from mainnet to run your scenario.
Program And IDL
program_so points at compiled SBF/BPF bytes. idl_path or [lineage].idl_source points at local source material used to author or inspect the adapter.
protocol = "generic"
program_so = "../../target/deploy/my_protocol.so"
idl_path = "../../target/idl/my_protocol.json"Accounts
Shared accounts exist once per scenario, while agent accounts are created per actor. Oracle accounts may declare an owner for sibling-program or Pyth-style account-owner checks.
space is the byte size of the account data; it must match the serialized size of your Rust #[account] struct (Anchor's space = 8 + ... discriminator + fields).
[accounts.pool]
kind = "shared"
space = 200
[accounts.user_position]
kind = "agent"
space = 80
[accounts.oracle]
kind = "shared"
space = 3312
owner = { pubkey = "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH" }Decoder Presets And Raw Layouts
Generic adapters can also observe external account bytes without a protocol-specific decoder. Add decoder metadata to an account and map its decoded fields through the same [state_mapping] block as IDL account fields.
[accounts.vault_src]
kind = "shared"
space = 165
decoder = "spl_token_account"
[accounts.mint]
kind = "shared"
space = 82
decoder = "spl_mint"Built-in presets are aliases over static layouts: spl_token_account exposes mint, owner, amount, state, and delegated_amount; spl_mint exposes supply, decimals, and is_initialized.
Raw layouts use fixed byte offsets. Supported field types are u8, u64, u128, i64, i128, bool, and pubkey. Lint rejects unknown presets, unknown field types, mappings to undeclared decoder fields, and offsets that exceed the account's declared space.
[accounts.some_account]
kind = "shared"
space = 96
[accounts.some_account.decoder]
kind = "layout"
[accounts.some_account.decoder.fields.amount]
type = "u64"
offset = 64
[accounts.some_account.decoder.fields.authority]
type = "pubkey"
offset = 8Actions And Instructions
Instructions map program calls to logical actions that personas can fire. Runtime-bound args such as amount are named in the adapter and supplied by deterministic behavior policies during the run.
[instructions] binds an IDL instruction name to a logical action and names the runtime-computed numeric arg. [actions.<name>] declares the action itself — label is the dashboard display name, takes lists the numeric args the runtime provides per call.
[instructions]
deposit = { action = "deposit", amount = "amount" }
borrow = { action = "borrow", amount = "amount" }
repay = { action = "repay", amount = "amount" }
withdraw = { action = "withdraw", amount = "amount" }
liquidate = { action = "liquidate", amount = "repay_amount" }
[actions.deposit]
label = "Deposit"
takes = ["amount"]
[actions.liquidate]
label = "Liquidate"
takes = ["repay_amount"]Multi-arg instructions
An [instructions.<ix>] mapping accepts exactly three keys: action, an optional amount, and an optional args table. amount names the single IDL arg the runtime computes per call (the "primary" numeric). Every other IDL arg goes through args as either a literal (int / bool / base58 string) or a "@persona.<key>" reference that pulls the value from the calling persona's TOML. takes = [...] on the action lists all IDL arg names in IDL order — that's what tells the engine how to Borsh-encode the call.
Example AMM-style binding — swap(amount_in, min_amount_out, direction): amount_in is computed at runtime, min_amount_out is pinned to a literal, and direction comes from each persona so different personas can commit to different sides.
[instructions.swap]
action = "swap"
amount = "amount_in"
args = { min_amount_out = 1, direction = "@persona.direction" }
[actions.swap]
label = "Swap one reserve side for the other"
takes = ["amount_in", "min_amount_out", "direction"]Two values that both need fresh per-call computation aren't expressible in one binding today — model one as amount and the other as "@persona.<key>", then let the adapter's [personas.*] blocks decide.
Observations
Observations are the typed surface that personas read in their triggers and that invariants compare against. They're declared in two paired blocks:
[state_mapping]— translates real on-chain account fields or decoder fields (<account>.<field>paths from your IDL or layout decoder) to logical observation keys. The LHS must match a declared account field; the RHS is the name personas and invariants will reference.[observations]— declares the type of each observation key. Supported types:"uint","int","float","bool","pubkey","map".
Real example from the shipped lending adapter — six raw account fields surfaced as five logical observations (position.debt reuses the same logical name for the per-agent field; the engine scopes lookups by account):
[state_mapping]
"pool.total_deposits" = "tvl"
"pool.total_borrows" = "debt"
"pool.bad_debt" = "bad_debt"
"position.collateral" = "collateral"
"position.debt" = "debt"
"position.liquidated" = "liquidated"
[observations]
tvl = "uint"
debt = "uint"
bad_debt = "uint"
collateral = "uint"
liquidated = "bool"Decoder fields use the same public surface. This AMM mapping observes SPL token vault balances without teaching Riptide about a specific AMM:
[accounts.vault_src]
kind = "shared"
space = 165
decoder = "spl_token_account"
[accounts.vault_dst]
kind = "shared"
space = 165
decoder = "spl_token_account"
[state_mapping]
"vault_src.amount" = "pool.reserve_src"
"vault_dst.amount" = "pool.reserve_dst"
[observations]
"pool.reserve_src" = "uint"
"pool.reserve_dst" = "uint"With those declarations in place, a persona can write triggers = [{ if = "pool.utilization > 9000", … }] and an invariant can compare position.debt against position.collateral — both reading the same typed values the engine extracted from on-chain state every tick.
Personas
A persona is a deterministic behavior policy for one class of actor (steady LP, panic whale, arb bot, liquidator). Each agent in a run is assigned one persona — where the scenario sets how many agents act, the persona sets how each one behaves. Personas live in the adapter's [personas.<slug>] blocks.
Each block declares four fields:
label— human-readable name for reports and the dashboard.action_rate_multiplier— scales how often this persona acts per tick relative to the baseline rate (1.0 = baseline, 0.5 = half as often, 2.0 = twice as often).action_weights— relative likelihood of each adapter action when the persona acts. Keys must match action names declared in[actions.*].triggersoptional — conditional overrides. Each entry hasif(an expression over observations),then(an action name), andweight_boost(the multiplier applied to that action's weight when the condition holds).
Real example — a panic-exiter for a liquid-staking adapter that stake-dominates in calm markets but switches to aggressive request_unstake the moment pool.exchange_rate_bps drops below 9_500:
[personas.panic-exiter]
label = "Panic exiter"
action_rate_multiplier = 1.0
action_weights = { stake = 0.05, request_unstake = 0.8, claim_unstake = 0.15 }
triggers = [
{ if = "pool.exchange_rate_bps < 9500", then = "request_unstake", weight_boost = 5.0 },
]An optional persona_args table supplies per-persona named values that [instructions.<ix>].args entries can reference via "@persona.<name>" — useful when one action has a discrete variant (e.g. swap direction) and you want different personas to commit to different sides.
Invariants
An invariant is a machine-checkable property the engine evaluates every tick. When one fires, the run records the firing tick, the run is marked failed, and the engine exits non-zero so CI can gate on it. Invariants live in the adapter — they are committed alongside the program, not reinvented per scenario.
Two declaration forms:
[[invariants]]raw — compares one observation field against a literal usingop(==,!=,<,<=,>,>=). Works on every adapter; fields are the logical observation keys you declared in[observations].[[semantics.invariants]]semantic — evaluates a deterministicexprover the typed economic surface (e.g.debt_value <= collateral_value). Available today on adapters that declare a versioned[semantics]class; shipped fixtures coverlending.v1,perps-margin.v1,amm.v1,lst.v1, andstablecoin.v1.severityis"warn"or"error".
Minimal raw example — a cumulative-counter invariant that fires the first tick the protocol records bad debt:
[[invariants]]
name = "no_bad_debt"
field = "pool.bad_debt"
op = "=="
value = 0The init stub leaves invariant blocks as a TODO — you pick the ones that make sense for your protocol and tune the bounds to match.
Economic Semantics
Semantics is the typed economic layer above raw account fields. The shipped protocol fixtures declare versioned classes for lending, perpetuals, AMMs, liquid staking, and stablecoins. The lending adapter's class = "lending.v1" surface declares roles such as position, reserve, oracle, and liquidation_config; derived values such as collateral_value, debt_value, and health_factor; collection observations such as worst_health_factor; and expression invariants.
[semantics]
class = "lending.v1"
[semantics.derived]
collateral_value = "position.collateral_amount * reserve.collateral_price"
debt_value = "position.debt_amount"
health_factor = "liquidation_threshold_value / max(debt_value, 1)"
[[semantics.invariants]]
name = "ltv_below_max"
expr = "debt_value <= max_borrow_value"
severity = "warn"Lineage, Lint, Adapt & Explain
[lineage] is reviewer metadata pointing back at the IDL source the adapter was authored against. Four commands work against an adapter TOML — each answers a different question:
riptide lint <program>— static validation against the JSON IDL named in[lineage].idl_source. No build, no network, no engine. JSON-IDL-backed adapters are machine-checked; non-JSON source lineage warns honestly; missing lineage skips rather than claiming coverage.riptide adapt --adapter .riptide/adapters/<program>.toml— adapter-only end-to-end smoke test through the local engine. Loads the TOML, validates it against the schema, fires a single write-action, and confirms observable state moved. This is useful when zeroed bootstrap accounts are enough. If your program needs SPL vaults, PDAs, sibling programs, or external account bytes, use a harness-backedriptide run --harness .riptide/harness --seeds 1smoke instead.riptide explain <program>— pretty-prints the parsed adapter in reviewer-readable form: runtime info, accounts, instructions, state mapping, actions, observations, personas, invariants, semantics, oracles. Inspection-only — no IDL fetch, no engine smoke. Use it to confirm an authored or generated adapter parses to what you intended before sharing it for review.riptide lineage <program>— prints the adapter's[lineage]block in reviewer-readable form. Inspection-only — no IDL fetch, no validation against the IDL. Use it when a reviewer wants to see the authorship trail (source IDL, provenance, hand-authored vs auto-generated) without running anything.
riptide lint <program>
riptide adapt --adapter .riptide/adapters/<program>.toml
riptide explain <program>
riptide lineage <program>Integration Recipe — Onboarding Any Protocol
Riptide's current onboarding path is deliberately generic. The adapter maps program bytes, IDL accounts, instructions, raw layout decoders, observations, personas, and invariants. The optional harness writes the concrete account bytes that make the program boot before tick 0.
Product direction: new protocols should start as mappings plus harness setup, not as one-off protocol decoders in Riptide core. Versioned semantic classes are available in the shipped protocol fixtures, but the execution path still runs through the generic SBF/IDL adapter runtime unless a future release adds a dedicated primitive. Profile hints route setup toward useful vocabularies, personas, scenarios, invariants, and semantics through /riptide-config or the advanced init wizard.
1. Pick the closest init protocol
Plain riptide init --protocol <class> records the closest profile hint while keeping the adapter as a thin generic placeholder. /riptide-config then uses that hint while it authors the real adapter, harness, scenarios, and invariants. If the closest bucket is wrong, pick custom and author the surface directly. The advanced riptide init --wizard path can still select starter personas and scenarios during init.
2. Map the raw program surface
Point Riptide at the compiled .so, IDL, accounts, instructions, and observations. Use IDL-backed account fields for your own program state. Use decoder presets or raw layouts for external byte layouts such as SPL Token vaults.
3. Add setup only where the adapter cannot
If zeroed bootstrap accounts are not enough, generate a harness and create the real setup state there: SPL mints and token accounts, PDAs, oracle mocks, sibling programs, and prefilled account data. The adapter remains the public shape; the harness is just pre-tick setup.
4. Declare observations and invariants
Map account fields and decoded fields into observation keys, then add raw invariants over those keys. For adapters that declare [semantics], you can also add semantic derived values and semantic invariants.
5. Write scenarios as sampled experiments
A scenario names agents, ticks, seeds, and active persona slugs. Start with one seed while wiring the adapter and harness; then run a larger sweep when the smoke passes. The riptide-config skill can draft additional run configs from the adapter shape.
6. Inspect the evidence
Review action success, state movement, invariant rows, run collection, canonical hash, and rerun script. For the anchor-uniswap-v2 proof, the AMM reserves are real SPL token account balances observed through the generic spl_token_account preset, while the harness creates the vault and user token account bytes before tick 0.
Cross-references: adapter authoring, harness setup, scenarios, replay artifacts, architecture, adapter lineage.