Monerium B2B onramp: forwarder contracts, keeper, managed-profile wiring, and deposit webhooks - #1272
Merged
Merged
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 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ 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
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.
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
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.
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
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.Keeper backend (
apps/api/src/api/services/monerium-b2b/)SwapExecutedblock/log boundary. Conversion net isusdcOut - fee, excluding unrelated USDC swept by the contract's full-balanceforwardedvalue.Managed-profile wiring
financial_operationsledger; reconciliation reads require the exact profile + chain + forwarder tuple.closedis terminal.Disabled-by-default rollout
The entire feature is dark unless
MONERIUM_B2B_ENABLED=trueand startup validates the complete Monerium B2B credential/key/RPC/factory configuration underFLOW_VARIANT=mykobo.When the flag is absent or not exactly
true: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 (SwapExecutedlog boundary).Migration execution remains an operational single-instance requirement. Migration 076 refuses a lossy rollback after allocation rows exist.
Verification
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, anddocs/security-spec/05-integrations/monerium-b2b.md.Review focus