@faremeter/flex-solana reference.
Program ID: EcfUgNgDXmBx4Xns2qZLE54xpM7V1N6PL8MdDW1syujS
For full source, see github.com/faremeter/flex/programs/flex.
Constants
| Constant | Value | Notes |
|---|---|---|
MAX_SPLITS | 5 | Recipients per authorization. |
MAX_MINTS | 8 | Distinct token mints per escrow. |
MAX_PENDING | 16 | Concurrent pending settlements per escrow. Re-exported from the TypeScript SDK as MAX_PENDING_SETTLEMENTS. |
MIN_REFUND_TIMEOUT_SLOTS | 150 | ~1 minute at 400 ms/slot. |
MAX_REFUND_TIMEOUT_SLOTS | 1,296,000 | ~6 days. |
MIN_DEADMAN_TIMEOUT_SLOTS | 1,000 | ~6.7 minutes. |
MAX_DEADMAN_TIMEOUT_SLOTS | 2,592,000 | ~12 days. |
deadman_timeout_slots >= 2 * refund_timeout_slots is enforced at create_escrow time.
PDAs
| Account | Seeds | Authority |
|---|---|---|
| Escrow | [b"escrow", owner, index] | Owner + Facilitator |
| Vault (token acct) | [b"token", escrow, mint] | Program (PDA signer) |
| Session Key | [b"session", escrow, session_key] | Owner |
| Pending Settlement | [b"pending", escrow, authorization_id] | Facilitator |
index and authorization_id are both u64 little-endian.
Discovery
Because the escrow PDA includes a numericindex, clients cannot derive a single canonical address. Use getProgramAccounts with memcmp filters on the account data, or the helpers findEscrowsByOwner / findEscrowsByFacilitator. Byte offsets after the 8-byte Anchor discriminator:
| Field | Type | Offset | Size |
|---|---|---|---|
| version | u8 | 8 | 1 |
| owner | Pubkey | 9 | 32 |
| facilitator | Pubkey | 41 | 32 |
| index | u64 | 73 | 8 |
Account structures
Escrow
| Field | Type | Notes |
|---|---|---|
version | u8 | Schema version for future migrations. |
owner | Pubkey | Client. |
facilitator | Pubkey | The single registered facilitator. |
index | u64 | PDA disambiguator. |
pending_count | u64 | Open pending settlements. |
mint_count | u64 | Active vault token accounts. |
refund_timeout_slots | u64 | Refund window duration. |
deadman_timeout_slots | u64 | Inactivity timeout for emergency_close and void_pending. |
last_activity_slot | u64 | Updated by facilitator-signed instructions only. |
max_session_keys | u8 | 0 means unlimited. |
session_key_count | u8 | Includes revoked-but-not-closed keys. |
bump | u8 | PDA bump. |
Session Key
| Field | Type | Notes |
|---|---|---|
version | u8 | |
escrow | Pubkey | Parent escrow. |
key | Pubkey | Ed25519 public key. |
created_at_slot | u64 | |
expires_at_slot | Option<u64> | Optional auto-expiry. |
active | bool | |
revoked_at_slot | Option<u64> | Set by revoke_session_key. |
revocation_grace_period_slots | u64 | Settlement window after revocation. |
bump | u8 |
Pending Settlement
| Field | Type | Notes |
|---|---|---|
version | u8 | |
escrow | Pubkey | |
mint | Pubkey | |
amount | u64 | Current amount; reducible by refund. |
original_amount | u64 | The submitted settle_amount. |
max_amount | u64 | Client-signed ceiling (audit only). |
authorization_id | u64 | Replay protection (in PDA seeds). |
expires_at_slot | u64 | |
submitted_at_slot | u64 | Refund window starts here. |
session_key | Pubkey | The signing key. |
split_count | u8 | 1..=MAX_SPLITS. |
splits | [SplitEntry; MAX_SPLITS] | Fixed-size; first split_count entries are valid. |
bump | u8 |
SplitEntry is { recipient: Pubkey, bps: u16 }.
Instructions
Account management
create_escrow(index, facilitator, refund_timeout_slots, deadman_timeout_slots, max_session_keys)
Signers: owner.
Constraints:
MIN_REFUND_TIMEOUT_SLOTS <= refund_timeout_slots <= MAX_REFUND_TIMEOUT_SLOTSMIN_DEADMAN_TIMEOUT_SLOTS <= deadman_timeout_slots <= MAX_DEADMAN_TIMEOUT_SLOTSdeadman_timeout_slots >= 2 * refund_timeout_slots
deposit(amount)
Signers: depositor (anyone).
Constraints: amount > 0; mint_count < MAX_MINTS when adding a new mint.
The vault PDA is created on first deposit per mint. The depositor pays the rent and forfeits it on close (rent goes to the owner). Deposits do not update last_activity_slot.
close_escrow
Signers: owner + facilitator.
Constraints: pending_count == 0. The instruction expects mint_count * 2 remaining accounts: pairs of (vault_pda, owner_destination_token_account). Each vault is fully drained to its destination, then closed; finally the escrow PDA itself is closed. Each pair is validated and duplicate mints are rejected.
emergency_close
Signers: owner.
The deadman switch: lets the owner unilaterally recover funds when the facilitator has gone dark. Callable when current_slot > last_activity_slot + deadman_timeout_slots AND pending_count == 0 AND session_key_count == 0.
The owner must clear pending settlements with void_pending and close session keys with revoke_session_key + close_session_key before this instruction will succeed. Same remaining-accounts shape as close_escrow: mint_count * 2 writable token accounts, alternating (vault, owner_destination) per mint.
Session keys
register_session_key(session_key, expires_at_slot, revocation_grace_period_slots)
Signers: owner.
Constraints:
max_session_keys == 0 || session_key_count < max_session_keysrevocation_grace_period_slots < escrow.refund_timeout_slots
session_key_count includes revoked-but-not-closed keys. Keys in their grace period still consume a slot.
revoke_session_key
Signers: owner.
Sets revoked_at_slot. Authorizations signed before revocation remain submittable until revoked_at_slot + revocation_grace_period_slots.
close_session_key
Signers: owner.
Constraints: key must be revoked and the grace period must have elapsed. Returns rent to the owner and decrements session_key_count.
Settlement
submit_authorization(mint, max_amount, settle_amount, authorization_id, expires_at_slot, splits)
Signers: facilitator (must match escrow.facilitator).
Validation:
pending_count < MAX_PENDINGcurrent_slot < expires_at_slotexpires_at_slot <= current_slot + escrow.refund_timeout_slotssettle_amount > 0andsettle_amount <= max_amount- Session key is
active, or revoked and within grace period splitsis non-empty,<= MAX_SPLITS,bps > 0per entry, sum to 10,000, all recipients unique- An Ed25519 verify instruction must precede this one in the transaction; the program checks that the signed message matches a recomputed
serializePaymentAuthorization(...)using the supplied parameters
PendingSettlement PDA seeded by (escrow, authorization_id) (init fails on duplicate IDs), increments escrow.pending_count, updates escrow.last_activity_slot.
refund(refund_amount)
Signers: facilitator.
Constraints: current_slot < submitted_at_slot + escrow.refund_timeout_slots; refund_amount > 0 and <= pending.amount.
Decreases pending.amount by refund_amount. If the amount drops to zero, closes the pending settlement (returning rent to the facilitator). Updates escrow.last_activity_slot.
finalize
Signers: anyone (permissionless crank).
Constraints: current_slot >= submitted_at_slot + escrow.refund_timeout_slots.
Distributes pending.amount from the vault to recipients per pending.splits (proportional to bps), closes the pending settlement, decrements escrow.pending_count. Returns the pending PDA’s rent to the facilitator. Does not update last_activity_slot — see “Activity tracking” below.
Remaining accounts: one writable destination token account per split entry, in the same order as pending.splits.
void_pending
Signers: owner OR facilitator (validated against escrow.owner / escrow.facilitator).
Closes a stuck pending settlement so the escrow can eventually be closed. Allowed when either:
current_slot > last_activity_slot + deadman_timeout_slots(the deadman has fired), ORcurrent_slot > submitted_at_slot + refund_timeout_slots + deadman_timeout_slots(the pending settlement has been parked past its useful life, even though the facilitator may still be otherwise active).
escrow.pending_count decrements, and the funds remain in the escrow vault. last_activity_slot is not updated.
Activity tracking
last_activity_slot is updated by exactly two instructions: submit_authorization and refund. Both require the facilitator’s signature, so the timer reflects genuine engagement. finalize (permissionless) and deposit (anyone) do not touch it. This ensures a malicious facilitator cannot keep the escrow alive by cranking finalizations while ignoring new business.
Errors
The program emits errors as Anchor custom error codes (offset 6000). The TypeScript SDK exports each as a constant (e.g.,FLEX_ERROR__SESSION_KEY_EXPIRED) and provides getFlexErrorMessage(code) for human-readable text. Notable errors:
| Constant | When |
|---|---|
FLEX_ERROR__AUTHORIZATION_EXPIRED | current_slot >= expires_at_slot at submission. |
FLEX_ERROR__EXPIRY_TOO_FAR | expires_at_slot > current_slot + refund_timeout_slots. |
FLEX_ERROR__INSUFFICIENT_BALANCE | Vault balance can’t cover settle_amount. |
FLEX_ERROR__INVALID_SIGNATURE | Ed25519 verify failed or message didn’t match recomputed authorization. |
FLEX_ERROR__INVALID_ED25519_INSTRUCTION | The preceding instruction is not a valid Ed25519 verify. |
FLEX_ERROR__SESSION_KEY_EXPIRED | Session key past expires_at_slot. |
FLEX_ERROR__SESSION_KEY_REVOKED | Session key revoked and grace period elapsed. |
FLEX_ERROR__SESSION_KEY_GRACE_PERIOD_ACTIVE | Tried to close a revoked key while still in grace period. |
FLEX_ERROR__SESSION_KEY_STILL_ACTIVE | Tried to close a session key that hasn’t been revoked. |
FLEX_ERROR__SESSION_KEY_LIMIT_REACHED | session_key_count == max_session_keys. |
FLEX_ERROR__PENDING_LIMIT_REACHED | pending_count == MAX_PENDING. |
FLEX_ERROR__PENDING_SETTLEMENTS_EXIST | Tried to close an escrow with pending_count > 0. |
FLEX_ERROR__MINT_LIMIT_REACHED | mint_count == MAX_MINTS. |
FLEX_ERROR__REFUND_AMOUNT_ZERO | refund_amount == 0. |
FLEX_ERROR__REFUND_EXCEEDS_AMOUNT | refund_amount > pending.amount. |
FLEX_ERROR__REFUND_WINDOW_EXPIRED | Tried to refund after the refund window closed. |
FLEX_ERROR__REFUND_WINDOW_NOT_EXPIRED | Tried to finalize before the refund window closed. |
FLEX_ERROR__REFUND_TIMEOUT_TOO_SHORT | refund_timeout_slots < MIN_REFUND_TIMEOUT_SLOTS at create. |
FLEX_ERROR__REFUND_TIMEOUT_TOO_LONG | refund_timeout_slots > MAX_REFUND_TIMEOUT_SLOTS at create. |
FLEX_ERROR__DEADMAN_TIMEOUT_TOO_SHORT | deadman_timeout_slots < MIN_DEADMAN_TIMEOUT_SLOTS. |
FLEX_ERROR__DEADMAN_TIMEOUT_TOO_LONG | deadman_timeout_slots > MAX_DEADMAN_TIMEOUT_SLOTS. |
FLEX_ERROR__DEADMAN_TOO_CLOSE_TO_REFUND | deadman_timeout_slots < 2 * refund_timeout_slots. |
FLEX_ERROR__DEADMAN_NOT_EXPIRED | emergency_close called before the deadman timeout elapsed. |
FLEX_ERROR__VOID_CONDITION_NOT_MET | void_pending called outside both allowed windows. |
FLEX_ERROR__INVALID_VOID_AUTHORITY | void_pending signer is neither the escrow owner nor the facilitator. |
FLEX_ERROR__SESSION_KEYS_EXIST | emergency_close called while session_key_count > 0. |
FLEX_ERROR__OWNER_ONLY | An owner-only instruction was signed by someone else. |
FLEX_ERROR__SPLIT_CALCULATION_OVERFLOW | Split distribution math overflowed at finalize time. |
FLEX_ERROR__SESSION_KEY_ALREADY_EXPIRED | Tried to use a session key past its expires_at_slot. |
FLEX_ERROR__GRACE_PERIOD_EXCEEDS_REFUND_TIMEOUT | revocation_grace_period_slots >= refund_timeout_slots at registration. |
FLEX_ERROR__FINALIZATION_DEADLINE_PASSED | Tried to finalize outside the allowed deadline window. |
FLEX_ERROR__SETTLE_AMOUNT_ZERO | settle_amount == 0. |
FLEX_ERROR__SETTLE_EXCEEDS_MAX | settle_amount > max_amount. |
FLEX_ERROR__INVALID_SPLIT_COUNT | Empty splits or more than MAX_SPLITS. |
FLEX_ERROR__INVALID_SPLIT_BPS | Split bps don’t sum to 10,000. |
FLEX_ERROR__SPLIT_BPS_ZERO | A split entry has bps == 0. |
FLEX_ERROR__DUPLICATE_SPLIT_RECIPIENT | Duplicate recipient in splits — collapse with mergeSplits first. |
FLEX_ERROR__INVALID_SPLIT_RECIPIENT | Recipient is not a valid token account for the mint. |
FLEX_ERROR__INVALID_TOKEN_ACCOUNT_PAIR | A (vault, destination) pair in close_escrow failed validation. |
FLEX_ERROR__DUPLICATE_ACCOUNTS | Duplicate mint in close_escrow remaining accounts. |
FLEX_ERROR__UNAUTHORIZED_FACILITATOR | Signer doesn’t match escrow.facilitator. |
Security
The on-chain program enforces all the invariants above. The honest-facilitator assumption only matters for what the facilitator chooses to submit: it can refuse to submit a valid authorization, but it cannot fabricate one. Splits, amounts, mints, and recipients are bound to the client’s signature. Failure modes worth thinking through:- Session key compromise alone — attacker can sign authorizations but not submit them. No funds at risk without facilitator cooperation.
- Session key + facilitator collusion — attacker can drain up to the signed
max_amountper authorization, into recipients the client signed for. - Facilitator goes dark — client recovers funds via
void_pending(per stuck pending settlement) →revoke_session_key+close_session_key(per registered key) →emergency_close, all gated bydeadman_timeout_slots. - Middleware compromise — middleware never signs payments and never holds keys. It can refuse to serve requests but cannot move funds.
- Verify all split recipients before signing (treat the splits vector as part of the contract you’re signing, not a facilitator parameter).
- Use short-lived session keys with
expires_at_slot. - Fund escrows in proportion to expected usage; rotate to a fresh escrow if a key is suspected compromised.
- Monitor on-chain pending settlements for the escrow with
findPendingSettlementsByEscrow.
Further reading
- Concepts — protocol-level model.
- Quickstart — end-to-end client flow.
- Facilitator — operator-side primitives.
- API Reference — generated TypeScript SDK reference.