Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks - #1272
Open
ebma wants to merge 57 commits into
Open
Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks#1272ebma wants to merge 57 commits into
ebma wants to merge 57 commits into
Conversation
…actory + unit tests
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vrtx-dashboard canceled.
|
✅ Deploy Preview for vortexfi canceled.
|
…solicited-USDC units
…ebhook inbox table
…-review r1 report
…k inbox, deposit processor
…ation, dormancy gate, cron worker
…IBAN issued to forwarder
…lidated), remainder re-arm, gap tests
…oks, terms inputs
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.
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.
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
marked this pull request as ready for review
August 26, 2026 19:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
VortexForwardercontract; 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.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
POST /v1/admin/monerium-b2b/accountsprovisions the managed child (business entity under the partner manager), imports the Monerium KYB approval intoprovider_customers/kyc_cases, verifies the deployed forwarder on chain, and binds it viamonerium_accounts.vortex_profile_id.PATCH …/accounts/:id/statushandles activation (gated on the issued IBAN), suspension, and closure.financial_operationsexactly-once ledger; theiban.updatedwebhook records the IBAN (never overwrites).GET /v1/monerium-b2b/accountand/depositsunder 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 newwebhook_deliveriesoutbox 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
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).RECOVERY_HASHquestion 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 indocs/adr-0005-monerium-b2b-onramp.md, gates and deploy checklist indocs/operations-monerium-b2b-rollout.md, operator procedures indocs/operations-monerium-b2b-runbook.md, end-to-end architecture indocs/architecture-monerium-b2b-onramp.md.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 sharedMoneriumApiService. 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.isValidSignatureconstraint (encoding, chainid binding, replay, the disabledRECOVERY_HASHslot)SwapExecutedadoption)