Skip to content

Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks - #1272

Open
ebma wants to merge 57 commits into
stagingfrom
vortex-monerium-v2
Open

Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks#1272
ebma wants to merge 57 commits into
stagingfrom
vortex-monerium-v2

Conversation

@ebma

@ebma ebma commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

End-to-end quoteless EUR → USDC onramp for KYB'd corporate clients (partner KYC reliance): each client gets a Monerium IBAN in their own name whose EURe mints to a per-client VortexForwarder contract; a keeper swaps EURe→EURC→USDC on Uniswap v3 (Chainlink-bounded minOut) and forwards to the client's own wallet. Deliberately not part of the one-shot ramp state machine — accounts are persistent and repeatedly funded.

Contracts (contracts/monerium-forwarder/, Foundry)

  • VortexForwarder: per-client EIP-1167 clone. EIP-1271 restricted to the fixed Monerium link message (EIP-191 hash, chainid-bound), attestor-signed — never validates redeem orders. Oracle-checked swap-and-forward, stranding marker, permissionless dead-man sweep to the client's mandatory fallback address, client/guardian role split.
  • VortexForwarderFactory: CREATE2 clones, atomic init, bounded operational params, 2-step guardian.
  • 29 unit + 4 mainnet-fork + 5 stateful-invariant tests; internal adversarial review r1 dispositioned.

Keeper backend (apps/api/src/api/services/monerium-b2b/)

HMAC webhook receiver with a durable persist-before-200 inbox; deposit processor with a forward-only status lattice under per-forwarder advisory locks; block-cursor mint watcher (12-block reorg lag, unattributed-inflow flagging); conversion executor with execution-before-send rows, pre-broadcast nonce persistence and on-chain crash recovery, cross-process send serialization, and snapshot-based pro-rata attribution (R04); dormancy gate; four detection-only monitors; manifest generate/verify tooling and a consolidated operations runbook.

Managed-profile wiring

  • Mapping: idempotent POST /v1/admin/monerium-b2b/accounts provisions the managed child (business entity under the partner manager), imports the Monerium KYB approval into provider_customers/kyc_cases, verifies the deployed forwarder on chain, and binds it via monerium_accounts.vortex_profile_id. PATCH …/accounts/:id/status handles activation (gated on the issued IBAN), suspension, and closure.
  • Onboarding automation: the keeper links the forwarder (attestor signature) and requests the IBAN through the profile-scoped financial_operations exactly-once ledger; the iban.updated webhook records the IBAN (never overwrites).
  • Read surface: GET /v1/monerium-b2b/account and /deposits under standard managed-profile delegation (EU corridor, business type) or the child's own credential.

Webhooks

New public account-scoped event family DEPOSIT_RECEIVED / DEPOSIT_CONVERTED (32-block confirmation depth): registered with the manager's own secret key (no quote/session, no mixing with transaction events, never a silent default), delivered durably at-least-once through a new webhook_deliveries outbox with claim-based dispatch and backoff — a failing endpoint no longer deactivates the subscription. Existing transaction webhooks are unchanged.

Review

A deep multi-lens review (8 finder lenses + adversarial verification) produced 19 confirmed findings — all fixed on this branch, including: deposit-webhook registration was dead at the HTTP layer; a broadcast-vs-persist crash window that corrupted attribution; the keeper running on both flow-variant backends; webhook-first deposits losing their on-chain mint identity; and the missing OpenAPI entries.

Migrations

069/070 (renumbered from 051/052; rename map covers dev DBs), 071 (vortex_profile_id), 072 (webhook_deliveries), 073 (deposit event markers), 074 (execution nonce).

Status

  • All behavior is inert until the MONERIUM_B2B_* keeper env plus the shared whitelabel credentials (MONERIUM_WHITELABEL_CLIENT_ID/SECRET) are configured (keeper self-gates; the worker runs only on the mykobo flow variant).
  • Gates before launch: G1 Monerium written-approval package (the RECOVERY_HASH question is resolved — per Monerium, recovery validates the same ownership message as linking, so the whitelisted link hash already covers it; written confirmation folds into G1), G2 legal (custody/MiCA), G3 external audit, G4 pilot. All parameters are decided — registry and accepted risks in docs/adr-0005-monerium-b2b-onramp.md, gates and deploy checklist in docs/operations-monerium-b2b-rollout.md, operator procedures in docs/operations-monerium-b2b-runbook.md, end-to-end architecture in docs/architecture-monerium-b2b-onramp.md.
  • Synced with the Monerium reintegration work (Monerium reintegration #1359): the shared whitelabel client (packages/shared/src/services/monerium/, cherry-picked from Monerium reintegration #1359) is now the single Monerium transport — the B2B module's internal client is deleted and its onboarding/monitoring provider calls go through the shared MoneriumApiService. The only remaining relationship to Monerium reintegration #1359 is that its residue (the dormant Polygon consumer flow, the legacy ramp-OAuth contract removal, the dashboard-doc updates) lands independently via staging.

Review focus

  • VortexForwarder.isValidSignature constraint (encoding, chainid binding, replay, the disabled RECOVERY_HASH slot)
  • The conversion executor's crash-recovery classifier (nonce consumption + unclaimed SwapExecuted adoption)
  • Webhook tenancy: the manager-owner delivery branch (security-spec invariant 9) and the deposit-family registration rules
  • Asset-exit exhaustiveness and guardian pause staying protective-only (contract invariant suite)

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit bfadc6a
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a90a61e9f9af50008484e1e
😎 Deploy Preview https://deploy-preview-1272--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit bfadc6a
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6a90a61e3ba9af0007fb3204

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi canceled.

Name Link
🔨 Latest commit bfadc6a
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a90a61ecd6d710008083d03

ebma added 19 commits July 17, 2026 11:44
Renumber the Monerium B2B migrations 051/052 to 069/070 past staging's
current head (068); README additions ported into the rewritten layout.
The workspace and its scripts were committed without the matching bun.lock
entry, so frozen-lockfile installs failed on this branch.
With the models registered, the postgres dropTable assigns
options.supportsSearchPath during ENUM-type cleanup and throws on an
undefined options argument. The migrator test suite now reverts across
this migration (revertMigration to 066), so a bare dropTable aborted
down() midway and left the schema partially dropped for every later
integration suite.
Corporates that Monerium onboards to the whitelabel app under the
partner's KYC reliance enter Vortex through an idempotent admin
endpoint: it provisions the managed child (business entity), mirrors
the approved KYB into provider_customers/kyc_cases, and binds the
deployed forwarder as a monerium account owned by that profile via the
new vortex_profile_id link. Divergent replays are conflicts, never
overwrites; pre-mapping account rows are adopted when they match.
The keeper now advances every mapped onboarding account: attestor-signed
address link and IBAN request run through the profile-scoped
financial_operations ledger (exactly-once across crashes and retries),
and the issued IBAN is recorded from the iban.updated webhook without
ever overwriting an existing one (an IBAN change is the association
monitor's alert condition). Activation after the penny test moves from
raw SQL to an admin status endpoint gated on the issued IBAN.
GET /v1/monerium-b2b/account and /deposits let the partner manager (via
X-Managed-Profile-Id delegation) or the child's own credential poll the
account state and deposit-to-conversion history. Strictly effective-user
scoped with the standard EU/business managed-profile policy; R09
unattributed inflows never surface. Docs and security spec updated for
the EU corridor's B2B account scope.
DEPOSIT_RECEIVED and DEPOSIT_CONVERTED extend the webhook contract for
business EUR onramp accounts: quoteless envelopes carrying the deposit
and its conversion outcome, deliverable to the account's controlling
manager. Existing transaction payloads are unchanged.
ebma added 11 commits August 26, 2026 09:42
The controller's own quoteId/sessionId presence check fired before the
service's account-family branch, so the documented deposit-event
registration always returned 400. The service owns that validation
(transaction events need exactly one target, deposit events none); the
new HTTP-level regression test goes through the real route, which the
service-level tests bypassed.
Both flow-variant backends share one database and one keeper key; two
concurrent keepers would race nonces and double-broadcast. Same
ownership rule the provider status workers already follow in this
block.
Four review findings on the executor, fixed together because they share
the send path:

- The swap nonce is persisted before any broadcast (migration 074), and
  hashless pending rows are now classified against on-chain state
  (nonce consumption + unclaimed SwapExecuted logs) instead of being
  silently failed: a crash or DB error between broadcast and the hash
  update no longer corrupts R04 attribution or double-executes.
- Pending-check and execution-row create run under one forwarder-lock
  acquisition, closing the double-broadcast window between them.
- Receipt lookups distinguish TransactionReceiptNotFoundError from RPC
  failure, so an RPC outage can no longer run the stale clock into a
  false Failed on a swap that succeeded.
- Nonce derivation and broadcasts serialize across processes via a
  keeper-send advisory lock (the pause-immune poke path included).
- The stranding marker now arms for suspended/closed/dormant accounts:
  the dead-man sweep exists precisely for accounts nobody operates.
An order.updated 'processed' delivery without meta.txHash advanced the
deposit to Minted before the watcher saw the mint, and the amount-match
branch required Pending — so the real mint was recorded as an
unattributed duplicate and the deposit never gained the chain identity
R04 depends on. Amount matching now covers any open row without
recorded chain identity. The scan also lags the head by a 12-block
confirmation depth, since the (chain_id, tx_hash, log_index) identity
is not reorg-stable, and a hash-matched mint whose on-chain value
disagrees with the webhook amount is now an error-level alert.
The admin mapping endpoint now reads the clone's destination,
fallbackAddress, feeBps, and factory registration from chain (when a
read RPC is configured) and rejects any divergence before anything is
persisted — a mistyped clone address would otherwise be linked to the
client's Monerium profile within a keeper cycle and its config adopted
by the R07 monitor as owner-authorized. A feeBps divergence on replay
is now also a conflict instead of a silent idempotent match.
Order-event inbox processing gets end-to-end DB coverage (create,
forward-only advance, regression rejection, delivery dedup, unknown
forwarder, hold/release); the outbox gets a concurrent-dispatch test
proving the skip-locked claims never double-deliver; and the onboarding
financial-operation failure branches are exercised. Doing so surfaced a
real wedge: a transient provider failure parked the ledger row in
'unknown' with reconciliation required forever — link/IBAN performs now
prove no-side-effect (link is synchronous upstream; IBAN issuance is
unique per address) and signal FinancialOperationRejectedError so the
next cycle retries cleanly.
Per-item isolation for the deposit-event emitter loops and the
stranded-balance monitor (one failing item no longer blocks siblings);
the Monerium webhook route gets its own 100kb body limit like the
Avenia mount instead of the global 20mb; outbox settle failures are
logged instead of discarded; SequelizeMeta rename entries cover the
051/052 -> 069/070 renumbering for development databases; the account
status endpoint's suspended -> active transition is asserted; and a
stale migration reference in the webhook-event model comment is
corrected.
Sent/abandoned delivery rows and long-processed inbox rows are pruned
after 30 days (dedup only needs the retry horizon), so neither durable
table grows without bound.
Invariant 2 now matches the shipped validator (chainid binding, EIP-191
hash only, the reserved RECOVERY_HASH slot — the raw-keccak variant was
removed post-G0), and the keeper/mapping invariants record the new
nonce-based crash recovery, single-lock double-send guard, reorg scan
lag, pause-immune stranding marker, and provision-time forwarder
verification.
…e OpenAPI spec

The two public GET endpoints and the DEPOSIT_RECEIVED/DEPOSIT_CONVERTED
event family were documented in prose but absent from the machine-readable
contract that feeds the endpoint catalog and codegen; the webhook
registration schema now states the deposit-family rules.
Every open parameter gets a concrete recommendation to accept or
overrule before deploy; T1 is rewritten as an open three-way decision,
recording the pilot-only permissive-validator option under
consideration together with the redeem-path and custody tradeoffs it
carries.
@ebma ebma changed the title Monerium B2B zero-touch onramp: PRD, implementation plan, forwarder contracts Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks Aug 26, 2026
ebma and others added 13 commits August 26, 2026 14:36
One maintained end-to-end picture — onboarding and deposit-to-payout
sequences, batching semantics, fee immutability, and the data model —
with the security spec, registry, and runbooks as the detail layers.
A deposit bigger than perSwapCap could never be selected for any
execution (eureIn is capped and only shrinks as the balance drains), so
it stayed unallocated forever and head-of-line blocked attribution for
every deposit behind it — DEPOSIT_CONVERTED never fired even though the
funds converted correctly on chain. An oversized oldest deposit now
attaches to the execution that begins converting it, with its pro-rata
share clamped to the swapped amount so the allocation still sums
exactly. Found while walking a worked example, not by the review.
P11 records the guardian-settable feeBps direction (timelocked
increases, MAX_FEE_BPS cap) pending the 24h-vs-48h timelock choice; O1
records the backend-enforced migration procedure and why an on-chain
timelock cannot gate an IBAN move.
P11 decided (Marcel, 2026-08-26): feeBps becomes guardian-adjustable
within the immutable MAX_FEE_BPS. Increases announce on-chain and apply
permissionlessly only after FEE_INCREASE_TIMELOCK (24 h), so a client
whose SEPA transfer is in flight cannot be swapped under a silently
higher fee; decreases and cancel-via-restate are immediate. The
invariant suite's config-integrity check now tracks every legal fee
transition through a ghost model instead of asserting immutability, and
the backend config monitor reconciles guardian fee changes like other
authorized transitions instead of alarming.
System map, onboarding and deposit-to-payout sequence diagrams,
watcher/cursor mechanics, lifecycle state diagrams, an ER view of the
data model, and the monitoring section — plus explicit labeling that
the monerium_* tables belong exclusively to the B2B flow (the legacy
OAuth integration owns no tables).
The forwarder whitelists the exact link/recovery message hashes, so an
unannounced message change on Monerium's side fail-closes onboarding of
new clients; an advance-notice obligation makes that a planned
implementation update instead of a surprise outage.
The shared MoneriumApiService is now the single Monerium transport: the
B2B module's internal whitelabel client is deleted and onboarding and
monitoring call the shared client through a narrow adapter, keeping the
dependency-injection seams intact. Credentials consolidate on
MONERIUM_WHITELABEL_CLIENT_ID/SECRET and MONERIUM_API_URL (one whitelabel
app = one credential set); MONERIUM_B2B_* keeps only the chain/keeper
settings. The attestor's link message now aliases the shared constant so
the signed and transmitted bytes cannot drift.
The committed vortex.openapi.d.ts predated a path reordering in
vortex.openapi.json, so docs:api:check failed on a clean tree; this is
the pinned generator's output for the unchanged spec.
Monerium confirmed (verbally, 2026-08-26) that issuer recovery presents
the same 'I hereby declare that I am the address owner.' message as
linking, whose hash the forwarder already whitelists: recovery works
with the constrained validator as built, RECOVERY_HASH stays zero, and
the pilot permissive-validator option is withdrawn. Written
confirmation folds into G1 item 3. Also adds the O2 registry row: the
immutable FEE_RECIPIENT treasury address and guardian key custody are
deploy-time decisions.
P8 was decided 2026-07-17 (Chainlink EUR/USD weekend gaps observed up
to 48h) and confirmed final today; the unit/fork/invariant configs
still carried the 26h placeholder that would revert most weekends.
The docs/prd working set and docs/runbooks are absorbed into four
maintained documents per docs/README.md rules: adr-0005 (decisions,
final parameter registry, accepted risks), the architecture doc,
operations-monerium-b2b-rollout (gates, deploy checklist, terms
inputs), and operations-monerium-b2b-runbook (onboarding, incidents,
triage, dormancy, migration). The consumer PRD survives as
proposal-monerium-consumer-onramp; everything else lives in git
history. All code and spec cross-references repointed.
@ebma
ebma marked this pull request as ready for review August 26, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants