Skip to content

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

Merged
ebma merged 97 commits into
stagingfrom
vortex-monerium-v2
Sep 11, 2026
Merged

ebma merged 97 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; a keeper swaps EURe→EURC→USDC on Uniswap v3 with a Chainlink-bounded minimum output and forwards to the client's fixed wallet. This is deliberately separate from 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 with chain/clone replay protection, oracle-checked swap-and-forward, fee timelock, stranding marker, permissionless dead-man sweep, and client/guardian role separation.
  • VortexForwarderFactory: CREATE2 clones, atomic initialization, bounded operational parameters, and two-step guardian transfer.
  • 36 unit tests, 5 stateful invariant tests, and 4 archive-RPC mainnet-fork tests.

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

  • Monerium v1 HMAC verification over signed id + timestamp + raw bytes, with a durable persist-before-200 inbox.
  • Strict EUR issue-order, profile, chain, amount, and mint-identity validation; forward-only deposit states and exact late-order/unattributed-mint reconciliation.
  • Mint watcher with a persisted cursor and 12-block reorg lag. Only settled, chain-indexed mints make an account a conversion candidate.
  • Crash-safe conversion execution: pre-send reservation CAS, poke-before-swap ordering, durable swap nonce/block identity, bounded exact recovery scans, and cross-process keeper-send serialization.
  • Cursor-gated N:M deposit allocation at the exact SwapExecuted block/log boundary. Conversion net is usdcOut - fee, excluding unrelated USDC swept by the contract's full-balance forwarded value.
  • Dormancy protection, manager deposit events through the durable outbox, and detection-only association/config/liquidity/stranding monitors.

Managed-profile wiring

  • Idempotent admin mapping validates the forwarder against the configured trusted factory, reads back its immutable/mutable config, and creates the managed child + approved KYB mirror + account atomically.
  • Onboarding link and IBAN provider writes use the profile-scoped financial_operations ledger; reconciliation reads require the exact profile + chain + forwarder tuple.
  • Account state changes use the explicit lifecycle graph; closed is terminal.
  • Account/deposit reads use standard effective-user managed-profile authorization. Deposit webhook subscriptions are manager-owned and account-scoped.

Disabled-by-default rollout

The entire feature is dark unless MONERIUM_B2B_ENABLED=true and startup validates the complete Monerium B2B credential/key/RPC/factory configuration under FLOW_VARIANT=mykobo.

When the flag is absent or not exactly true:

  • public/admin Monerium B2B routes and the route-scoped raw-body parser are not mounted;
  • the Monerium B2B worker does not start;
  • deposit-event webhook registration is rejected;
  • existing generic webhook outbox delivery continues normally.

Migrations

069/070 (renumbered from 051/052 with legacy metadata reconciliation), 071 (vortex_profile_id), 072 (webhook_deliveries), 073 (deposit event markers), 074 (execution nonce), 075 (pre-broadcast block), 076 (N:M deposit allocations), and 077 (SwapExecuted log boundary).

Migration execution remains an operational single-instance requirement. Migration 076 refuses a lossy rollback after allocation rows exist.

Verification

  • Full GitHub CI is green: build, Biome, monorepo type-check, wire-contract gate, and all package coverage suites.
  • Local focused database suites cover migrations, allocation boundaries, late/ambiguous mint reconciliation, lifecycle transitions, onboarding, manager events, and account authorization.
  • Deep multi-lens Vortex review plus repeated completeness/adversarial verification is dry: no remaining actionable P0–P2 findings.

Launch status

Code merge does not activate production behavior. Remaining launch gates are G1 Monerium written approval, G2 legal scope, G3 external audit, and G4 pilot. The rollout checklist also requires zero EURe balances on mapped forwarders before the first cursor bootstrap.

See docs/adr-0005-monerium-b2b-onramp.md, docs/operations-monerium-b2b-rollout.md, docs/operations-monerium-b2b-runbook.md, docs/architecture-monerium-b2b-onramp.md, and docs/security-spec/05-integrations/monerium-b2b.md.

Review focus

  • Default-off activation boundary and fail-fast enabled-mode configuration.
  • Webhook signature/scope validation and late mint/order reconciliation.
  • Conversion reservation, nonce-bound recovery, and exact cursor-gated allocation.
  • Trusted-factory provisioning and manager/effective-user tenancy.
  • Asset-exit guarantees and guardian pause remaining protective-only.

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 7cf6464
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6aa3d3730cb73000074a188c
😎 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 ready!

Name Link
🔨 Latest commit 7cf6464
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6aa3d3740b45110007a670dd
😎 Deploy Preview https://deploy-preview-1272--vrtx-dashboard.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 vortexfi canceled.

Name Link
🔨 Latest commit 7cf6464
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6aa3d3738feab700087b3e26

ebma added 19 commits July 17, 2026 11:44
Integrations wanting encrypted or vault-backed ephemeral recovery storage
previously had no hook: the secrets never crossed the public SDK surface,
and storeEphemeralKeys: false silently disabled the backup entirely. The
callback replaces the built-in file/localStorage persistence and keeps
the fail-closed registration contract.
Sync README, ARCHITECTURE, and the ephemeral-accounts security spec with
the new storeEphemeralKeysCallback: built-in storage stays local-only,
a configured callback shifts destination custody to the integrator, and
both paths keep the fail-closed registration contract.
The skill claimed integrators could set storeEphemeralKeys: false and
persist the keys themselves, but the secrets never crossed the public
SDK surface, so that flag alone just disabled the recovery backup.
Point custom-storage integrations at storeEphemeralKeysCallback.
Additive only: StoredEphemeralKey, StoreEphemeralKeysCallback, and the
optional storeEphemeralKeysCallback config field. No existing surface
changed, so live integrators are unaffected.
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.
ebma added 26 commits September 1, 2026 16:25
The API's squidrouter core now Big-parses toAmountUSD for routed subsidy
valuation, but the wire schema only required a non-empty string. Mirror
Big.js's accepted grammar so an unparsable value fails loudly at the wire
boundary, matching how the other numerically consumed fields are guarded.
The Squid probe fallback was recorded only via quote-context notes, which
reach no persistent sink, so a degraded Squid USD valuation (the one newly
plausible silent trigger on exotic-token corridors) left no operational
signal. Emit a structured warn from the catch, note the skipped adjustment
when the oracle target is non-positive, and pin the retention>1 branch.
The fake's absolute toAmountUsd default of "1" modeled ~1% value retention
for every probe; harmless today only because pre-existing scenario partners
disable subsidy. A proportional default (raw Base USDC at $1) keeps the next
discount-enabled routed-onramp scenario from encoding a 100x-loss route.
Copilot review follow-up: enforcing toAmountUSD parseability at the wire
schema (or parsing it unguarded in the shared route helper) made every
route consumer fail on a field only the SubsidizePost probe consumes -
AveniaMint's fee probe and the swap leg would abort quote creation on a
malformed value the probe is designed to degrade on. Keep the schema
presence-only, parse the field tolerantly into Big|null in the helper, and
let the probe's existing warn + 1:1 fallback absorb unusable values. This
supersedes the big-parseable schema validation, whose regex also admitted
a leading plus sign that Big.js rejects.
Copilot follow-up: requiring the field meant a Squid response omitting its
USD estimate still aborted every shared getRoute caller before the tolerant
parse could run - the one absence case the previous round left fatal.
Normalize absence to an empty string so the shared type stays a plain
string, non-consuming route callers are unaffected, and the SubsidizePost
probe hits its existing warn + 1:1 fallback. The min(1) requirement
predates this PR, so this extends the availability decision rather than
fixing a regression.
…aluation

Prevent routed quotes from inflating subsidies across token units
The endpoint only read the static token config, so tokens the quote
engine accepts via the dynamic Squid Router list (e.g. PAXG) were never
advertised. Read the merged dynamic config instead, dedupe the alias
keys, and expose rampTypes so integrators can see that routed tokens
are BUY-only. The network filter is now required to keep responses
per-network.
Squid sends a native source token (ETH, POL) as msg.value, so the route's
transaction value carries the swapped principal on top of the router fee.
Pricing the whole value as network fee exceeded the bridged amount, the
fee distribution zeroed the swap input, and every native-token SELL quote
failed with "Input amount for swap must be greater than 0". Subtract the
principal for native sources so only the router fee is charged.
The block-flow port gated every EVM SELL definition on the static token
enum and config, while the legacy route resolver accepted any token the
Squid-discovered catalog knew. Resolve SELL sources through the same
merged token catalog BUY destinations use, restoring sells of routed
tokens such as PAXG.
With the SELL catalog resolving routed tokens, rampTypes no longer
depends on whether an EVM token comes from the static config.
Startup re-resolves every pending quote and resumable ramp through the
catalog's match predicates and exits the process on failure. Matching
SELL sources against live Squid discovery meant one routed-token ramp
plus a failed token fetch at boot would crash-loop the API. Match SELL
sources structurally, as BUY destinations already are, and let the flow
input resolver reject unknown symbols with a 400 at quote time.
rampTypes claimed BUY and SELL for Moonbeam, testnets, and AssetHub,
although quote creation rejects those networks or has retired their
only corridors. Derive the directions from the same guards the quote
service applies, naming the AssetHub retirement so both share it.
The phase processor enqueues the ramp-completion email without awaiting
it, so the INSERT can still be in flight when the next test's beforeEach
truncates every table. Postgres then deadlocks the two (the INSERT's FK
check waits for a table the TRUNCATE holds, and vice versa) and the next
test fails at random. Route the enqueue through a background-work
registry from the fake world and have truncateAllTables wait for it.
…email-race

Drain fire-and-forget work before truncating test tables
…rencies-routed-tokens

List routed EVM tokens in supported cryptocurrencies
Agents reading the docs inferred that a custom UI or API-driven onboarding requires managed profiles. State the standalone-profile alternative where that inference happens.
Browser integrations fail at the CORS preflight until Vortex adds their exact origin, and the docs never said how to get one added.
…rifications

Clarify that managed profiles are optional and document the browser origin allowlist
@ebma
ebma merged commit 16b8f9f into staging Sep 11, 2026
7 of 8 checks passed
@ebma
ebma deleted the vortex-monerium-v2 branch September 11, 2026 14:31
ebma added a commit that referenced this pull request Sep 14, 2026
Resolves conflicts against the Monerium B2B onramp (#1272), routed-token
catalog matching (#1363), oracle-anchored routed subsidy (#1362), the
provider_limit_exceeded rejection type (#1355), and the moved SDK Node
consumer examples (#1357).

- catalog.ts: keep staging's structural isOnChainToken match and reapply the
  dormant-EURe exclusion on top, so Squid discovery can never surface EURe as
  an offramp source.
- vars/preload/.env.example: keep both the MONERIUM_ISSUE_FEE_EUR requirement
  and the MONERIUM_B2B_* configuration.
- SDK examples: the EUR Node consumer examples are removed at their new
  location with their package scripts, matching this branch's deletion (the
  SDK EUR handler does not support the owner-permit flow).
- security-spec/monerium.md: this branch's white-label + active Polygon onramp
  spec, with staging's legacy OAuth onboarding section retained because those
  routes still exist.
- Remaining docs describe the direct-API Polygon EUR onramp as active, keep
  the B2B onramp and managed-profile recipient delegation from staging, and
  keep Mykobo as legacy recovery only.
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