Repository navigation
Reference-priced forwarder swaps with whole-deposit settlement and an automatic refund path - #1375
Merged
Merged
Conversation
✅ Deploy Preview for vrtx-dashboard ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vortexfi ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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
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.
…to feat/monerium-forwarder-fee-subsidy # Conflicts: # apps/api/src/api/controllers/admin/moneriumB2b.controller.ts # apps/api/src/api/controllers/monerium-b2b.controller.ts
…arder-fee-subsidy
…arder-fee-subsidy
…arder-fee-subsidy
Staging's wire-contract gate now snapshots every mounted route; the four routes are additive.
docs/README.md removes a proposal once accepted; ADR-0005's 2026-09-17 amendment holds the decisions and the architecture doc the behaviour. The proposal also still named a company-profile refund wallet, which per-client wallets replaced.
The exercise table still deployed the pre-2026-09-29 floor, minimum and cap (25/250/25k EURe) although it tells the reader to use ADR-0005's values (P6/P7: 1/1/10k). With the minimum at the floor, the step that lowered it has no job left.
082-084 were never deployed: 084 dropped a column 079 added, and 082/083 each added one column to tables 079/081 already change. A fresh database ends with the same Monerium schema (columns and enums compared against the unfolded chain). 085 keeps its name, so development databases that ran the old files stay consistent; umzug ignores their orphaned 082-084 entries.
Receipts and getLogs recovery decode through the standalone event items; nothing passed the forwarderAbi copies to a decoder, and keeping both in sync was a manual rule.
parseTicker, spreadBps and computeMid parsed bid and ask three times for one caller, and the zero-midpoint check could not fire after the empty-book check. The fetch seam existed only for tests, which now stub global fetch; the same tests pass against the old and new code, with every error message unchanged.
…point The handler re-implemented the locked forward-only transition the refund path already had. setDepositStatus now returns the refusal instead of dropping it; status codes, error codes and messages are unchanged and now pinned by the endpoint test. The handler's account 404 could not fire: deposits.account_id is NOT NULL with a restricting foreign key.
The executor reads the remaining EURe, the net USDC and the last swap time from a deposit's settlement state, and the tier cap, reference and route from a priced swap; swaps, convertedEureRaw and projection were returned for test assertions only. projectSwap keeps its own unit tests.
Like the per-client refund wallet clients, a viem wallet client over a plain private-key account holds no nonce or other state, so the module-level cache bought nothing.
…pers The worker re-listed the settling statuses the executor plans from, and the refund path copied errorText and the receipt timeout; a status added to one list but not the other would strand deposits.
The factory admits only one- and two-hop paths, so reversing them needs no byte walker; the new form matched the old on 2,000 random paths of both lengths and keeps its unit tests and error message.
_requireBatchAge reverted through mstore/revert to pass the error selector in; a bool helper with plain reverts at its three callers raises the same selectors under the same conditions, as the existing NotAuthorizedYet and DelayNotElapsed tests confirm.
The vault let its caller name any recipient, but its only caller, the forwarder, always passed address(this). Paying msg.sender makes that structural with one argument less; the contracts are not deployed, so the interface can still narrow. ADR-0005 and the security spec still described the recipient as the clone's destination, stale since subsidies started landing on the clone.
ToppingUp waited on floatTopupTxHash ?? surplusTxHash. After a confirmed top-up, a later surplus sweep left the old top-up hash first in line, so the step returned at once, still saw the unswept surplus and sent the sweep again. Each send now clears the other transfer's hash; the earlier transfer's amount stays on the row.
…arder-fee-subsidy
The managed-profiles page named only the three milestone events, and the flow doc sent SulPayments to a dashboard webhook screen that does not exist.
The rollout checklist covered mainnet only, and Sepolia has no usable EURe/USDC market: its one pool prices EURe at 0.71 USDC, so every sandbox payment would have reverted into a refund. The procedure seeds a Vortex-run 1 bps pool at the Chainlink price and was dry-run on a Sepolia fork, including the refund leg.
It describes a manifest-v2 deployment with the wrong tokens and a placeholder router that the current verifier rejects; the sandbox bring-up deploys a new factory and commits its own manifest.
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.
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").
feeBpsskim on whatever the DEX returns cannot express that.Decisions are recorded in the two dated amendments of
docs/adr-0005-monerium-b2b-onramp.md; behaviour indocs/architecture-monerium-b2b-onramp.md; the design rationale for the second part (approaches compared, feasibility findings) indocs/proposal-monerium-b2b-settlement-and-recovery.md.What changes
Contracts (
contracts/monerium-forwarder)targetPpm1250,floorPpm1500 at launch) behind the existing 24 h increase timelock;MAX_FEE_PPMcaps both. Three bands against a keeper-supplied reference the contract bounds toMAX_REFERENCE_DEVIATION_BPSaround Chainlink: surplus above the target is the fee, between floor and target passes through, below the floor is topped up from the new sharedVortexSubsidyVault(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 todestinationin one transfer;forwardAll()is permissionless afterTRIGGER_DELAY(a Vortex outage cannot trap converted funds).recover(eure, usdc): keeper-only, pays only the immutableRECOVERY_WALLET, and only once the clone's batch marker (batchOpenedAt, never re-timed by a chunk swap) isRECOVERY_DELAY(2 h) old. The contract, not the keeper, enforces the promised window. Not blocked by pauses (pause-then-recover is the incident sequence).fallbackAddress,sweep,setDestination,setClientPaused, the dead-man sweep andSWEEP_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)kind(swap|forward|recover) anddeposit_id, the N:M allocation join is dropped (the migration refuses an execution that spanned several deposits instead of guessing),fallback_addressgoes; 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 areturned_event_atmarker; newmonerium_recoveries.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), oneforwardof the summed net, and arecoverfor 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.recovery.ts,MONERIUM_B2B_AUTO_RECOVERY=off|alert|auto): deposits pastMONERIUM_B2B_RECOVERY_DEADLINE_MINUTES(120, from the mint) are reported or marked; once the keeper'srecoveris 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 depositrefundedwhen 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 secondrecoverwhile 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.RECOVERY_DELAY, error pastTRIGGER_DELAY), reference venue, refund queue and float; config drift treats a destination change as an incident.DEPOSIT_CONVERTEDfires on the confirmed forward and carriesforwardTxHash; newDEPOSIT_RETURNED(refunded amount, masked payer IBAN, redeem order, recover tx); the deposits read API gainsforwardTxHashand arefundblock; deposit statuses and the wire-contract snapshot follow.Subsidy ladder, per-swap cap and spot reference (2026-09-18)
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 everyMONERIUM_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.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.Docs
SLIPPAGE_BPS60, 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 beforeRECOVERY_DELAY, no chunk re-times a batch, no subsidy above the caller's cap); the 4 mainnet fork tests compile but were not run (noETH_RPC_URLlocally).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:checkclean.Reviewer notes
contracts/monerium-forwarder/manifests/is v2 and failsverify-manifestuntil the contracts are redeployed; expected.alert→autoswitch.DEPOSIT_RECEIVEDmay now reportstatus: "converting"when conversion started within the same keeper cycle (documented)..d.ts; whichever merges second re-runsbun docs:api:types.