| name | genswarms-payments-use |
| description | Wire the genswarms-payments object into a swarm: stable HD deposit addresses from a watch-only xpub, an idempotent settlement ledger, stamped payment_confirmed delivery to allowlisted targets, and the in-tree multi-chain USDC watcher. Use when adding payment settlement to a swarm, or debugging "address not credited" (unwatched chain / cursor held by a store failure / below confirmations / wrong contract), "deposit_address refused" (store down — fail closed — or untrusted source, or degraded_boot), "tick does nothing" (source not in trusted_sources, or the store's cursor read failing), or "everything refused / poll does nothing" (degraded_boot from a store outage at init — restart once the store recovers). Importer's guide — for internals read the README and checks/. |
genswarms-payments — using the package
Settlement hub object: one object owns beneficiary↔address identity, the
settlement ledger, and delivery — any number of payment modalities plug into
it via Genswarms.Payments.Method. v1 ships USDC in-tree; future modalities
(Stripe, x402) are sibling packages.
Wiring
Declare the object (see README for the full config block and every default):
xpub — required. Watch-only BIP32 public key. Never put an xprv
(private key) here or anywhere near this object — it only needs to watch,
never spend.
trusted_sources — required for anything to work. Fail-closed: empty ⇒
every deposit_address / payment_status / tick / ingest_event
message is silently ignored.
targets — required for anyone to get credited. Fail-closed: empty ⇒
settlements still record durably but nobody is ever delivered
payment_confirmed.
store_mod — optional, any subset of Genswarms.Payments.Store (8
optional callbacks) — but two groups must be all-or-nothing or init/1
raises: {put_address_binding, list_address_bindings} and
{payment_seen?, record_payment, get_last_scanned_block, put_last_scanned_block}. Without a store: memory-only, resets on restart
— fine in dev, not in prod.
chains — one map per EVM chain to watch (see README for every field);
only relevant if methods includes the USDC watcher (the default).
rpc_url is validated at init — a quote, backslash, or control character
raises (it rides a curl --config tempfile; see below).
A trusted source sends {"action":"deposit_address","beneficiary":"..."} to
mint/fetch a stable address, and {"action":"tick"} to run one watch round.
There is no internal scheduler — auto_tick/poll_interval_ms are
accepted but inert. Wire a scheduler object (e.g. genswarms-cron) as a
trusted source that delivers tick on an interval.
Durable accounting
Without store_mod, bindings and the settlement ledger are in-memory:
addresses and dedup state reset on restart (dev only). For production pass
store_mod: MyApp.PaymentsStore implementing any subset of the Store
contract; missing callbacks fall back to memory. Unlike budget reads in
sibling packages, settlement writes here fail closed — a store error on
the dedup read or the record write holds that settlement for the next round
rather than risk a double-credit or silent loss. The host owns the
schema/migrations.
Authorization lane (entry A: EIP-3009 → treasury)
A sibling package (e.g. genswarms-wallet-bridge) can have users sign
EIP-3009 authorizations whose USDC lands directly in a shared TREASURY
wallet instead of a per-beneficiary deposit address. This hub owns the
issued-authorization registry end to end and is the ONLY thing standing
between "a signed authorization" and "a credited payment":
- Configure
treasury_address on the chain(s) that use this lane. It is
NOT added to bindings/the watched-address map — the treasury has no
single beneficiary, so its Transfers are resolved by nonce correlation,
never by address lookup.
- A trusted source calls
issue_authorization with
{nonce, order_ref, beneficiary, amount_usd, valid_before} BEFORE the
authorization is ever submitted on chain. Order matters and there is no
safe reverse: issue in THIS hub first, THEN hand the nonce to whatever
signs/submits it (a keeper, a wallet-bridge order). Registering the order
with a downstream keeper BEFORE this hub has issued the nonce means a
Transfer can land in the treasury and get scanned before
issued_authorization/1 can ever resolve it — the credit rule (below)
refuses anything it cannot look up, so that money is recorded
unrecognised, not credited, and needs a manual operator reissue/release
to fix. Issue first, always.
store_mod needs 5 more optional callbacks, and with a treasury_address
configured they are REQUIRED to boot: record_issued_authorization/1,
issued_authorization/1, live_authorization_nonces/1,
mark_authorization_consumed/1, record_unrecognised_inflow/1. A store
missing any of them raises at init/1 unless you pass the explicit
allow_ephemeral: true opt-out — same stance, and the same opt-out, as the
durable-settlement-ledger gate. That opt-out only decides whether the
PROCESS boots, though: issue_authorization itself always refuses
per-action (no_authorization_store) when record_issued_authorization/1
isn't exported, with no ephemeral escape and no dependency on whether any
chain even configures treasury_address — a hub that answered ok: true
but could never register the nonce acknowledged money it can never credit
(the user signs, the money moves, and every payment lands unrecognised).
Partial coverage of either round trip is refused too, like every other
callback group in this package.
- A store fault ON the lookup (
issued_authorization/1 raising or exiting,
as opposed to answering nil) is not treated as "never issued": the
Transfer is HELD — not credited, not recorded unrecognised, cursor not
advanced, nonce left live — so a transient store outage cannot bury a real
payment forever. It re-presents and settles once the store recovers.
Key custody — the seed never touches the host
This object is watch-only by construction: it holds an xpub, so it can
derive deposit addresses and read balances, and it cannot sign anything. That
property is the whole security model of the deposit lane, and it is worth
exactly as much as the discipline around the seed the xpub came from.
The split. One seed produces two things. The private keys sign — they
move money. The xpub derives addresses and watches — it cannot. Only the
xpub is ever configured here. Everything below exists to keep it that way.
Before mainnet — a checklist, in order:
- Generate the seed offline: a hardware wallet, or a machine with no
network. Not on the host, not on a laptop that will later hold the host's
credentials.
- The seed never touches the host. Not in the object config, not in an
environment variable, not in a file on the box, not typed into a terminal
session on it. If it has been on the host once, treat it as compromised and
start again — this is cheap before deposits exist and impossible after.
- Export only the xpub and pass it as
xpub. That is the sole key
material this object should ever see.
allow_test_xpub must be absent in production. The denylist exists
because the well-known test seeds' key material is derivable by anyone, and
is matched on the decoded key, not the string — a re-encoded serialization
of the same key is still refused. The flag is an opt-in for local rigs and
nothing else.
- Back the seed up physically, in two or more separate locations. Losing
it makes every deposit ever made to a derived address permanently
unrecoverable. There is no recovery path on a public chain, and this object
cannot help — it never had the key.
- Decide who may sign, before you need to. A single holder with a single
device is a single point of failure in both directions: they can move
everything alone, and if they lose the device (or are unavailable) nobody
can move anything. Multisig or split custody is the ordinary answer for
funds that belong to an organisation rather than a person.
Collecting deposits without bringing the seed online. Derived deposit
addresses accumulate tokens and hold no native gas, so a naive sweep would
require funding each one. The workable shape keeps the seed offline
throughout:
- the host measures which addresses hold a balance (
sweep_report reports;
it never moves funds — see the D4 note below);
- the seed holder signs an EIP-3009
transferWithAuthorization per address,
offline, moving the balance to the treasury;
- a relayer submits those signatures and pays the gas. The relayer is a
separate key that holds only gas: it cannot alter the destination (it is
inside the signed payload) and cannot touch user funds.
Automating that sweep is a deliberate decision, not a default — it is the one
component that needs signing authority, and it is the only place where a
mistake spends money rather than mis-accounting it. Measure first; automate
when the stranded volume justifies the added surface.
What a compromised host costs you, stated plainly. With only the xpub
present, an attacker who owns the box learns every derived address and every
balance — a real privacy loss, permanent and unfixable by rotation, since the
chain code inside an xpub enumerates the whole tree. What they cannot do
is spend a single unit. That guarantee holds only while no xprv and no seed
has ever been near the host; it is void the moment one is.
A note on which lane you run. Deposits routed through the authorization
lane (entry A) land in the treasury directly and leave nothing to sweep. The
deposit-address lane exists for payers who can only send to an address (an
exchange withdrawal, typically) and cannot sign — those balances are what the
procedure above collects. The more traffic entry A carries, the rarer the
offline-signing ritual becomes.
Gotchas
- "address not credited" — check, in order: is the chain in
chains at
all (unwatched chain never gets scanned); is the store's cursor stuck
because a previous round's settlement was held back by a store failure
(fail-closed keeps the cursor from advancing — check logs for "FAIL
CLOSED, holding settlement" / "cursor write failed"); is the deposit still
below confirmations deep (reorg-safe by design — it will settle once the
chain advances); does the log's contract address actually match
usdc_contract for that chain (a Transfer-shaped log from an unrelated
contract is rejected by design, even from an otherwise-trusted RPC).
- "deposit_address refused" (
{"ok": false, ...}) — either the store is
down and this allocation fails closed ("error": "store_unavailable" —
never hand out an address whose binding isn't durably persisted), the
object is in degraded_boot ("error": "degraded_boot" — see below), or
the source isn't in trusted_sources (in which case there's no reply at
all, not even a refusal).
- "tick does nothing" — the sending source isn't in
trusted_sources
(silently ignored, same as any other untrusted message), the object is in
degraded_boot (poll is a no-op — see below), or the store's
get_last_scanned_block read is failing (poll proceeds but each chain's
scan can't compute its from, so nothing new is fetched that round).
- "everything refused / poll does nothing" — check
{"action": "health"} for "degraded_boot": true. It means the CONFIGURED store's
list_address_bindings/0 errored or raised at boot, so init/1 couldn't
trust the true watched-address set or next HD index and refused to guess
— poll/1 is a no-op and deposit_address is refused until the object is
restarted (it does not self-heal on its own; that's deliberate, so a
transient DB blip at boot doesn't crash-loop the object instead).
- Delivery of
payment_confirmed is at-least-once for transient per-target
failures (a raise, an EXIT, a throw) — a failing target is queued and
retried at the start of every subsequent tick, but the queue is
in-memory only, so a process crash between recording and delivering can
still drop a delivery (the settlement itself is never re-presented, since
it's already recorded — dedup by idempotency_key). Reconcile via
, not delivery receipt.
Adopting in a second host
The whole authorization lane (issue → watch → credit → tell the user →
dashboard) is host-portable: the package owns the logic, the host owns its
tables, its transport, and its voice. The first host (wingston) proved every
step live on Base Sepolia 2026-07; adopting is a recipe with an automatic
judge at each step — never archaeology of the first host's code:
-
Schema. Copy priv/reference_schema.sql into your migration system
(five tables; the money-bearing column notes are inline). Rename nothing
you don't have to.
-
Store adapter. Implement the Genswarms.Payments.Store callbacks
over those tables. The optional groups matter as groups: the six
authorization callbacks for the lane, authorization_by_order_ref/1 +
authorization_by_settlement/2 for the chat presenter, the two
list_* reads for the dashboard page.
-
Prove it. Genswarms.Payments.StoreConformance.run!(YourStore)
against a throwaway database, inside your own gates. Green with no skips
in the sections you adopted = your adapter honors the semantics the hub
and presenter rely on. Do NOT trust a hand-rolled fake instead: both
real defects this lane ever shipped were store doubles that answered
what the test hoped for.
-
Wire the presenter. Genswarms.Payments.TopupAck.result_fn/1 as the
keeper's result_fn and .credit_notice_fn/1 as the proxy's credit
seam — three injected functions (:store, :conversation_fn,
:deliver_fn), zero logic. Default English copy included;
:text_fn/:credit_text_fn override the voice. To get card-editing
(one card progressing instead of stacked messages, retiring the payment
link on signature), record card_chat_id/card_message_id from your
delivery effect when the top-up card lands.
-
Register the page. One probed line in your dashboard source:
Genswarms.Payments.Dashboard.dashboard_extension(store_mod: YourStore)
(guard with function_exported?, rescue to %{} — the page must never
break the feed).
Keeper results vocabulary (wallet-bridge ≥ 0.8.0): {:refused, reason} is
the simulation gate declining pre-broadcast (zero gas; reason is the
contract's decoded revert string) and {:failed, :reverted} is an on-chain
revert after a real broadcast. The default copy distinguishes them; if you
write your own, do not collapse them — the first host shipped that collapse
and told a user with insufficient USDC that their payment "didn't go
through on the chain".
Verification
./checks/run.sh — every checks/payments_*.exs (no Postgres, no network;
injected seams: fake store, injected rpc_fn/now_fn/deliver_fn).
Adopting hosts additionally run StoreConformance.run!/1 against their real
store (see above) — the first host wires it into its throwaway-PG gate.