Skip to content

Reference-priced forwarder swaps with whole-deposit settlement and an automatic refund path - #1375

Open
ebma wants to merge 59 commits into
stagingfrom
feat/monerium-forwarder-fee-subsidy
Open

ebma wants to merge 59 commits into
stagingfrom
feat/monerium-forwarder-fee-subsidy

Conversation

@ebma

@ebma ebma commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Why

Two things changed the forwarder design before launch, and both land here because the contracts from #1272 are not deployed yet (no clone migration, no "v2").

  1. The partner agreement fixes the client's rate against a reference: Coinbase EURC-USDC minus 12.5 bps, never worse than 15 bps below it. A flat feeBps skim on whatever the DEX returns cannot express that.
  2. Product requirements from SulPayments (2026-09-17): one USDC transfer per bank payment (chunks are an implementation detail), and an automatic refund of the exact EUR amount to the payer's bank account when a payment cannot be converted inside the promised window — with Vortex holding the funds for that refund, agreed commercially. This reverses the 2026-09-15 decisions "no bouncing" and "no Vortex-triggered sweep", and removes the client fallback role.

Decisions are recorded in the two dated amendments of docs/adr-0005-monerium-b2b-onramp.md; behaviour in docs/architecture-monerium-b2b-onramp.md; the design rationale for the second part (approaches compared, feasibility findings) in docs/proposal-monerium-b2b-settlement-and-recovery.md.

What changes

Contracts (contracts/monerium-forwarder)

  • Fee policy in ppm per clone (targetPpm 1250, floorPpm 1500 at launch) behind the existing 24 h increase timelock; MAX_FEE_PPM caps both. Three bands against a keeper-supplied reference the contract bounds to MAX_REFERENCE_DEVIATION_BPS around Chainlink: surplus above the target is the fee, between floor and target passes through, below the floor is topped up from the new shared VortexSubsidyVault (treasury-funded, pays only factory clones, per-swap cap and daily budget, pausable, withdraws only to the treasury).
  • swap(reference, route, amountIn) converts an explicit chunk and keeps the USDC (subsidy included) on the clone; forward(amount) pushes the whole converted payment to destination in one transfer; forwardAll() is permissionless after TRIGGER_DELAY (a Vortex outage cannot trap converted funds).
  • recover(eure, usdc): keeper-only, pays only the immutable RECOVERY_WALLET, and only once the clone's batch marker (batchOpenedAt, never re-timed by a chunk swap) is RECOVERY_DELAY (2 h) old. The contract, not the keeper, enforces the promised window. Not blocked by pauses (pause-then-recover is the incident sequence).
  • Removed: fallbackAddress, sweep, setDestination, setClientPaused, the dead-man sweep and SWEEP_DELAY. A destination change is a new clone.
  • SLIPPAGE_BPS (on the client's net after fee and subsidy) is 60 bps in every fixture: with a 2 h promise a weekend Chainlink gap that defers a swap turns into a refund, and the twelve-month replay shows ~80 h/year of floor-cause deferral at 40 bps vs two five-minute blips at 60. Factory route whitelist validated on chain; manifest scripts learn all of it (manifest v4).

Backend (apps/api)

  • Migrations 078–084: fee policy in ppm; execution reference/route/subsidy; executions gain kind (swap | forward | recover) and deposit_id, the N:M allocation join is dropped (the migration refuses an execution that spanned several deposits instead of guessing), fallback_address goes; deposits gain the settlement and refund states (converting, forwarded, recovering, refunded, recovery_failed), minted_at, the payer's IBAN/name from the issue order's counterpart, and a returned_event_at marker; new monerium_recoveries.
  • Keeper: prices every swap against a five-minute VWAP of Coinbase EURC-USDC candles (the venue was switched from the delisted EURC-USD, and a monitor now probes the product status), quotes every whitelisted route, mirrors the contract settlement (projectSwap) and defers rather than sending when the vault could not cover, the net would breach the floor, the reference is unavailable or out of band. It now serves one deposit at a time: chunk swaps bound to the deposit (never leaving sub-minimum dust when avoidable), one forward of the summed net, and a recover for a deposit marked for the refund path. Every keeper transaction shares the execution row, the nonce-before-broadcast identity and the calldata-exact crash recovery, per kind.
  • Refund path (recovery.ts, MONERIUM_B2B_AUTO_RECOVERY=off|alert|auto): deposits past MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES (120, from the mint) are reported or marked; once the keeper's recover is confirmed, one recovery at a time swaps the USDC back on the reversed route with a Chainlink-derived minimum, tops the dedicated recovery wallet up from the EURe float to the exact issue amount (or sweeps a surplus back), places the Monerium redeem order to the payer's IBAN with a memo that makes placement exactly-once, and marks the deposit refunded when Monerium processes it. Every step re-derives its work from the wallet's balances, so a lost hash never repeats a send; the executor refuses a second recover while a refund is in flight. Amounts of EUR 15,000 or more, a missing payer, rejected orders and exhausted retries park the deposit for the operator with the phase preserved. Admin endpoints mark a deposit for recovery and close or retry one by hand.
  • Monitors: depth over every route, subsidy-vault runway, batch stranding (warn past RECOVERY_DELAY, error past TRIGGER_DELAY), reference venue, refund queue and float; config drift treats a destination change as an incident.
  • Partner surface: DEPOSIT_CONVERTED fires on the confirmed forward and carries forwardTxHash; new DEPOSIT_RETURNED (refunded amount, masked payer IBAN, redeem order, recover tx); the deposits read API gains forwardTxHash and a refund block; deposit statuses and the wire-contract snapshot follow.

Subsidy ladder, per-swap cap and spot reference (2026-09-18)

  • The keeper waits for the market before Vortex pays a shortfall, and pays more the longer a chunk waits: MONERIUM_B2B_SUBSIDY_LADDER (seconds waited → max bps of the reference value; launch: nothing for six minutes, then 10 bps more every two minutes to 50, then 100 bps, held until the refund deadline). Per-chunk clock from the mint or the previous chunk's confirmation; the keeper re-quotes every MONERIUM_B2B_KEEPER_CYCLE_SECONDS (20 s); every deferral logs the shortfall so the ladder is tuned from data. The ladder is a Vortex spending policy, so it lives in config; the vault's cap (to be raised to 100 bps) and daily budget stay the hard bounds.
  • swap(reference, route, amountIn, maxSubsidy): the tier binds on chain, so a fill that moved between the quote and the swap cannot draw more than the tier. The contract learns nothing about time or ladders. Invariant: the vault never pays above the caller's cap.
  • Weekend drift is paid, not refunded: the Chainlink floor (SLIPPAGE_BPS, 60 bps) bounds the fee target and the subsidy floor from below, so when the reference sits more than ~45 bps under a stale Chainlink round the fee gives way first and the tier-bounded subsidy lifts the net to Chainlink − 60 bps instead of the swap reverting. A depeg beyond the tier and the vault cap still reverts; the permissionless path still pays nothing. The spot drift replay (one-minute closes, 2025-09 to 2026-09) is recorded in the ADR: at 60 bps only the 2025-10 depeg weekend outlives the 2 h window.
  • The reference is the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap (spot; an average lags a moving market and the lag becomes subsidy). A spread above 50 bps defers. The VWAP window column is dropped.

Docs

  • ADR-0005 amendments (fee bands and vault; whole-deposit settlement, Vortex-held recovery, fallback removal, SLIPPAGE_BPS 60, dormancy refunds), architecture, security spec (invariants, threat rows, audit checklist), runbook (§2.7 refund procedure and automation, triage rows), rollout (G1 re-approval items, terms rewrite incl. the custody disclosure, deploy checklist, ledger), API pages and OpenAPI.

Verification

  • forge test: 79 pass (unit, vault, invariants incl. pricing bounds, EURe/USDC exit exhaustiveness, no recovery before RECOVERY_DELAY, no chunk re-times a batch, no subsidy above the caller's cap); the 4 mainnet fork tests compile but were not run (no ETH_RPC_URL locally).
  • bun test (apps/api) on an isolated test database: 1976 pass, 0 fail (incl. the refund state machine with fakes, deadline marking, one-at-a-time gating, operator retry, DEPOSIT_RETURNED, the ladder and the midpoint reference). API typecheck, Biome, bun docs:api:check, bun wire-contract:check clean.

Reviewer notes

  • The old Sepolia manifest in contracts/monerium-forwarder/manifests/ is v2 and fails verify-manifest until the contracts are redeployed; expected.
  • Trust statement changed: Vortex keys can move a client's funds only to the immutable recovery wallet, only after the 2 h delay, and never anywhere else. G1 needs Monerium's re-approval of the Vortex-held fallback and of one company profile refunding many client corporates, plus whether returns above EUR 15,000 need a supporting document; G2 must scope the custody. Rollout terms §6 has the disclosure text.
  • Still open before a deployment (rollout ledger): sandbox SEPA simulation to confirm the payer counterpart on the wire (needs a sandbox dashboard login), fork run, recovery/float wallet onboarding on the Vortex company profile, vault and float funding, and the staged alert → auto switch.
  • DEPOSIT_RECEIVED may now report status: "converting" when conversion started within the same keeper cycle (documented).
  • Overlaps with Run the Monerium OAuth and white-label apps in parallel on the EUR onramp #1372 only on the generated OpenAPI JSON and .d.ts; whichever merges second re-runs bun docs:api:types.

@netlify

netlify Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

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

@netlify

netlify Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit b0f72f1
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6abe9a6c920f00000707dd00
😎 Deploy Preview https://deploy-preview-1375--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 Sep 15, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

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

ebma added 12 commits September 15, 2026 19:27
Additive only: ConversionExecutionPricing and the required execution block on DepositConvertedWebhookPayload.conversions[], mirrored in WebhookPayload and WebhookDeliveryAttempt.
_settle returned the vault's claimed subsidy without observing it, and setSubsidyVault is guardian-only, untimelocked and unvalidated: a no-op vault let a below-floor fill pass the net floor on paper while the client received the raw fill. Count the subsidy only after exactly the shortfall landed at destination, the same exact-delta pattern _swap uses.
The only net-floor tests landed in the subsidy branch; a reference 100 bps under Chainlink with a fill above its target exercises the fee branch and must still revert / defer on the oracle floor.
The floor on the net binds at SLIPPAGE_BPS - floorPpm (~25 bps) below Chainlink, well inside the 100 bps band; the weekend-gap open item framed it purely as a band question.
The monitor compares the raw quote to SLIPPAGE_BPS, but settlement now enforces the floor on the client's net: a raw impact above it is subsidized up to the vault cap and the keeper still executes. Keep the raw depth-vs-oracle signal, name what it means (every swap draws a subsidy, the permissionless path reverts) instead of calling for a pause, and fix the stale _minOut reference.
flatten treated arrays as leaves, so String([...]) collapsed every route to "[object Object]" and a same-length path or enabled change produced no diff.
The partner docs said five-minute VWAP unconditionally; the keeper widens to sixty minutes when the five carry no volume.
…arder-fee-subsidy

# Conflicts:
#	docs/api/pages/14-managed-profiles.md
…ecoveries

The keeper now serves one deposit at a time: chunk swaps bound to it (1 deposit :
N executions, explicit amountIn), one forward of the summed net once every chunk
is confirmed, and a recover for a deposit an operator marked for the refund path.
The N:M allocation join and the R04 snapshot attribution have no job left and go;
every keeper transaction shares the execution row, the nonce-before-broadcast
identity and the calldata-exact crash recovery, per kind. Deposits gain the
settlement and refund states, DEPOSIT_CONVERTED fires on the confirmed forward and
carries its hash, and the client-held fallback role leaves the account model, the
provisioning read-back and the monitors (a destination change is now an incident;
stranded batches warn past RECOVERY_DELAY). Two admin endpoints mark a deposit for
recovery and close or retry it by hand until the refund is automated.
Second ADR-0005 amendment (chunks accumulate on the clone, one forward per
payment, Vortex-held recovery wallet behind an on-chain 2 h delay, client
fallback role removed, SLIPPAGE_BPS 60), architecture flow and data model,
security-spec invariants and threat rows, runbook §2.7 refund procedure and
triage rows, rollout terms and ledger, webhook page and OpenAPI deposit shape.
The promised conversion window counts from the mint block, and a refund needs
the payer's IBAN and name; both come from data the flow already sees (the mint
log's block, the issue order's counterpart) and were not kept. Adds the
monerium_recoveries table the automated refund drives.
MONERIUM_B2B_AUTO_RECOVERY=alert reports deposits past the deadline, auto marks
them and drives the refund: once the keeper's recover is confirmed, one recovery
at a time swaps the USDC back on the reversed whitelisted route, tops the
dedicated recovery wallet up from the EURe float to the exact issue amount (or
sweeps a surplus back), places the Monerium redeem order to the payer's IBAN
with a memo that makes placement exactly-once, and marks the deposit refunded
when Monerium processes it. Every step re-derives its work from the wallet's
balances so a lost hash never repeats a send; the executor refuses a second
recover while a refund is in flight. Large amounts, a missing payer, rejected
orders and exhausted retries park the deposit for the operator with the phase
preserved. A refund monitor watches the queue and the float.
DEPOSIT_RETURNED joins the account-scoped deposit event family: fired once per
refunded deposit with the EUR amount refunded, the payer's IBAN masked to its
first and last four characters, Monerium's redeem order and the recover
transaction. The deposits read API gains the same facts as a refund block.
@ebma ebma changed the title Price forwarder swaps against a partner reference with fee bands and a subsidy vault Reference-priced forwarder swaps with whole-deposit settlement and an automatic refund path Sep 17, 2026
ebma added 22 commits September 18, 2026 10:33
The keeper now passes maxSubsidy into swap(): its escalation tier for the time
the chunk has waited. The contract binds the vault's top-up to it at execution,
so a fill that moved between the quote and the swap cannot draw more than the
tier; the vault's own cap and budget still apply on top. The invariant suite
proves the vault never pays above the caller's cap.
Product wants the keeper to wait for the market before Vortex pays a shortfall,
and to pay more the longer a chunk waits. MONERIUM_B2B_SUBSIDY_LADDER holds
the schedule (seconds waited to max bps of the reference value; launch: nothing
for six minutes, then 10 bps more every two minutes to 50, then 100). The clock
runs per chunk, from the mint or the previous chunk's confirmation; the tier is
passed into swap(..., maxSubsidy) and persisted for the calldata-exact crash
recovery. Deferrals log the shortfall so the ladder can be tuned from data, and
the keeper cycle is configurable (20 s) so a waiting chunk is re-quoted often.
Product moved the reference back to spot for the first iteration: a five-minute
average lags a moving market, and in a falling one the lag becomes subsidy. The
keeper now reads the EURC-USDC ticker's top of book just before each swap and
uses the midpoint; a last trade can be one-sided or stale on a quiet weekend,
and a spread above 50 bps is a thin book the keeper defers on instead of
pricing against. The averaging window has nothing left to record and goes.
…reverting

When the reference sits more than SLIPPAGE_BPS minus the policy floor below a
stale Chainlink round (weekend drift), a fill at the client's floor used to
revert and, under the promised window, refund. The Chainlink floor now bounds
the fee target and the subsidy floor from below: the fee gives way first, then
the keeper's tier-bounded subsidy lifts the net to the floor, within the vault's
cap. A depeg beyond what the tier and the vault cover still reverts; the
permissionless path still pays nothing and must clear the floor on its own.
…stions

Gives Vortex, SulPayments and Monerium one shared description of the onboarding, conversion and refund flow, and collects the questions each party still has to answer before the pilot.
The team decided not to enforce a meaningful minimum amount, and smaller chunks cut price impact and per-chunk subsidy exposure. Both are deploy and guardian settings, so only the parameter registry and the documents that quote them change.
The penny test was never agreed as a mandatory onboarding step. It stays a recommended check for exchange destinations and no longer appears in the partner-facing flow overview.
…to feat/monerium-forwarder-fee-subsidy

# Conflicts:
#	docs/api/openapi/vortex.openapi.json
#	docs/api/pages/07-webhooks.md
#	docs/api/pages/14-managed-profiles.md
…und addresses

Monerium's public API spec already answers several open questions, and it shows that any address linked to a profile can pay out through that profile's IBAN. That lets a refund leave from the client's own IBAN without a contract change, and lets SulPayments register destinations with Vortex instead of routing them through Monerium.
Monerium confirmed that the client profiles live in SulPayments' white-label app and answered most onboarding, notification and refund questions. The overview now reflects that setup, the destination handover by profile ID, and the remaining open points per party.
…the flow

SulPayments wants every deposit stage with IDs, amounts, timestamps and hold or failure status, and fetches IBANs from the Vortex API. The overview now maps those requirements against what is built and proposes the event and API changes that close the gaps.
Captures the per-partner app setup, how Monerium handles held and rejected payments, third-party payers, the refund approach agreed for the pilot, and SulPayments' onboarding status.
SulPayments needs every deposit stage with IDs, amounts, timestamps and hold or failure reasons, and fetches client IBANs from the API. DEPOSIT_UPDATED and ACCOUNT_UPDATED send the full deposit or account snapshot whenever it changes, the deposits and account calls return the same shapes, and GET /v1/monerium-b2b/accounts lists a manager's accounts, filterable by provider profile ID.
The flow overview and architecture map now show how each deposit stage reaches SulPayments and what is still left out.
Refunds now run from a wallet per client linked to that client's Monerium profile, so the single implementation-wide RECOVERY_WALLET becomes a per-clone recoveryAddress set by deployForwarder, with no setter and rejected when it equals the destination or a protected address.
Each client's refund wallet is derived from MONERIUM_B2B_REFUND_SEED and its Monerium profile ID, so one secret covers every client and the address is known before deployment. Onboarding links it to the client's profile, mapping rejects a clone whose recovery address is not that wallet, an admin endpoint returns it for deployment, and the refund runs on it with the float topping up its gas.
The decision record, architecture, security spec, runbook, rollout and flow overview now describe the per-client refund wallet instead of one Vortex recovery wallet on a company profile.
…view

SulPayments calls Monerium's KYB endpoints itself without a SatoshiPay proxy, Monerium releases production credentials after test data in SulPayments' sandbox app, and the destination endpoint is planned for the week of 2026-10-05, as sent to SulPayments and Monerium on 2026-10-01.
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.

1 participant