Repository navigation
Let the partner register a client's destination and onboard it automatically - #1408
Merged
Merged
Conversation
The partner needs to hand Vortex a client's destination by Monerium profile ID and have the forwarder set up without an operator by the week of 2026-10-05. The plan fixes the API contract, the flow and data model, the security controls and the decisions to confirm before coding.
…idy' into feat/monerium-b2b-destination-endpoint
…idy' into feat/monerium-b2b-destination-endpoint # Conflicts: # docs/README.md
Partner registrations will deploy clones from the backend; a guardian-managed deployer role lets that happen without keeping the guardian key hot, and must exist before the Sepolia and mainnet factories are deployed because the factory is not upgradeable.
…destination-endpoint
A partner's request to onboard a Monerium profile with a destination needs a home until its forwarder exists; monerium_accounts rows are always verified, deployed clones the mint watcher scans, so the request gets its own table.
The partner holds each new client's Monerium profile ID and payout wallet, but every client waited on an operator to deploy and map its forwarder. The bound partner manager now registers both through POST /v1/monerium-b2b/accounts; once Monerium approves the profile, the keeper deploys the clone with the factory deployer key at a CREATE2 salt per profile and destination, and maps it through the same verified provisioning as the admin call. Accounts activate on their own outside production.
The proposal was accepted with its decisions settled on 2026-10-06, so its rationale moves into ADR-0007 and the proposal goes, as docs/README.md asks; the partner API, flow overview, runbook and rollout describe the registration path.
✅ Deploy Preview for vortexfi canceled.
|
✅ Deploy Preview for vrtx-dashboard canceled.
|
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Production activation is the operator's check of an immutable destination, but the keeper converted for onboarding accounts too, and the partner reads the IBAN as soon as Monerium issues it, so a payment could reach an unchecked destination before anyone activated the account. Now only active, non-dormant accounts swap or forward; an earlier payment waits on the clone and the deadline refunds it.
Partners could only see a pending or rejected registration by replaying the POST, and operators had no way to find the accounts waiting for activation. GET /v1/monerium-b2b/registrations gives the partner its registrations; GET /v1/admin/monerium-b2b/accounts lists accounts by status with their partner manager. Both page like the existing lists, now through one helper.
Activation now holds payments back, so the penny test runs after it, and partners hand out an IBAN once the account is active.
Ponytail review of the PR: the keeper no longer blocks a cycle waiting for the deployment receipt (the next cycle finds the clone, as it already did after a crash), activation is one bulk update, and the snapshot type, a one-caller comparison, an unused deps parameter and ABI errors that cannot occur are gone.
The B2B docs named the pilot partner, a customer whose name does not belong in a public repository.
The B2B docs named the pilot partner, a customer whose name does not belong in a public repository.
… feat/monerium-b2b-destination-endpoint # Conflicts: # docs/operations-monerium-b2b-runbook.md # docs/product-monerium-b2b-flow.md
ebma
force-pushed
the
feat/monerium-b2b-destination-endpoint
branch
from
October 7, 2026 16:03
6ae673f to
09c9730
Compare
…revoke clones Every clone a deployer key deploys counts as a forwarder, and forwarders may draw vault subsidies, so a leaked hot deployer key could register clones nobody could remove, and it could occupy a client's predictable address with its own recovery address so the backend's verification fails for that client. The CREATE2 salt now commits to the destination, recovery address and fee policy, so the predicted address can only ever hold the intended clone, and a one-way guardian revokeForwarder takes a rogue clone out of the registry. The deployer tests are split and assert events and every guardian function a deployer must not reach.
Monerium's profile lifecycle includes review and closed, but the schema rejected both, so reading such a profile threw instead of returning a state the registration keeper and the personal flow can act on.
Activation is the operator's destination check for partner-registered accounts, yet it left no trace and the dormancy window of a never-converted account ran from its creation, so an account activated more than 60 days after mapping was paused at once. activated_at anchors the window and every status change is logged; an onboarding account whose check fails can now be suspended instead of lingering in the activation queue.
With activation an operator step, a fully linked account can wait days in onboarding; re-reading its links every keeper cycle cost two Monerium calls per account per cycle for no change. Link drift stays the association monitor's job.
A payment to an account that may not convert (not activated, suspended, or paused for dormancy) showed as minted with no waiting reason, indistinguishable from a fault. The new account_not_active reason is additive to DepositWaitingReason; the wire-contract snapshot records it.
A registration was rejected for good on any RPC, Monerium or deployer hiccup, and only the 20 oldest requested rows were ever looked at, so profiles awaiting approval starved newer ones. Now only definite reasons reject (Monerium rejected or closed the profile, the factory refused the arguments, a client conflict); everything else waits with a reason the partner reads, checked least recently first, one deployment per cycle, with dropped deployments sent again. At request time a client conflict is a 409 instead of a later rejection, a Monerium failure or 403 is a 503 instead of a 500 or a blamed input, impersonation tokens may not register, and a rejected profile can be registered again. The keeper adopts an account an operator mapped by hand, the step runs isolated after the money steps, and only the sandbox auto-activates. predictAddress follows the factory's salt binding.
Registrations were visible to operators only through SQL, and a partner typo could not be undone before the keeper deployed it. The admin list shows the keeper's progress; withdrawing a registration whose deployment was not sent rejects it, after which the partner registers the profile again.
An uppercase MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID passed validation but never matched the stored profile ID, so every registration got 403. The deployer also had to differ only from the keeper-side keys, not from the float wallet, which sends with implicit nonces too.
A huge ?offset= passed the integer check and Postgres answered bigint out of range, a 500 on every list that pages through pageOf.
…ones A keeper cycle acted on the rows it loaded at its start, so an operator withdrawal or the partner's new attempt landing mid-cycle could be overwritten, or an old destination mapped. Outcomes are now written only while the row is unchanged, and the row is locked while the account is created. A clone the guardian revoked sits at the same CREATE2 address forever, so its registration looped on deployment_pending; it is now rejected. A lagging RPC node reporting a fresh clone as unregistered at mapping time is retried instead of rejecting. An operator's mapping is adopted only for the registering manager's client. Registrations waiting for Monerium are asked again after a minute instead of every cycle, and the step stops after 15 seconds so a slow provider cannot stretch the keeper cycle.
The account_not_active reason was synced only when the keeper planned nothing, so after activation the deposits queued behind the one being converted kept reporting the hold. The test cast also failed the typecheck.
Onboarding automation skips suspended accounts, so one suspended before its IBAN could never be activated again.
Mutants reversing the admin account order, dropping the lowercasing of a replayed profile ID, or changing the mapped replay snapshot passed before.
A revoked clone's subsidy-dependent swaps revert, since the vault pays registered clones only; only its forward and recover paths are untouched.
Partners need the waiting reasons, the definite rejection causes, re-registration, the new 409/503 outcomes, and that an IBAN issued before activation is not yet payable.
This was referenced Oct 8, 2026
…destination-endpoint # Conflicts: # apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts # apps/api/src/api/services/monerium-b2b/conversion-executor.ts # docs/architecture-monerium-b2b-onramp.md # docs/security-spec/05-integrations/monerium-b2b.md
The activation gate cleared account_not_active after the deposits were loaded for planning, so a deposit released by activation and deferred in the same cycle kept a stale in-memory start time and the deferral wrote its new reason with waiting_since NULL; partners then saw the deposit's creation time. Syncing first lets planning load the cleared rows.
Merging staging switched the reserved-nonce re-send to canConvert, so a not-activated account's swap or forward is now consumed with a no-op instead of re-sent past the activation gate.
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 this does
The partner creates each client's profile in its own Monerium white-label app and knows the client's payout wallet, but every client waited on a Vortex operator to deploy a forwarder with
castand map it with an admin call. With this PR the partner registers the destination itself, and the keeper does the rest.POST /v1/monerium-b2b/accounts(manager key only, never an impersonation token, and only the manager named byMONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID): Monerium profile ID, destination, client reference, contact email. 202 for a new registration (or a new attempt after a rejection), 200 with the current state for an identical replay (destination, reference and email), 409MONERIUM_B2B_DESTINATION_CONFLICTfor any difference or a profile that already has an account, 409MONERIUM_B2B_CLIENT_CONFLICTwhen the reference or email belongs to another client, 422 when Monerium does not know the profile in the app or rejected or closed it, 503MONERIUM_B2B_PROVIDER_UNAVAILABLEwhen Monerium fails (nothing recorded). No KYB data.registration.ts): waits until Monerium reports the profileapproved, deploys the clone with a dedicated deployer key, then maps it through the existing verifiedprovisionMoneriumB2bAccount. Only definite causes reject a registration (Monerium rejected or closed the profile, the factory refused the arguments, a client conflict, an operator mapping that differs, a revoked clone, an operator withdrawal); everything transient waits with awaitingReasonthe partner reads. It checks the 20 least recently checked registrations per cycle (Monerium waits re-asked after a minute, 15 s budget), sends one deployment per cycle, resends a dropped one after 10 minutes, adopts an account an operator mapped by hand, writes only to the row it loaded (a withdrawal or new attempt mid-cycle wins), and runs after the money steps, isolated. Only the sandbox (SANDBOX_ENABLED=true) auto-activates; elsewhere activation stays the admin call.setDeployer), CREATE2 address bound to the whole initialization (predictAddress(destination, recoveryAddress, targetPpm, floorPpm, salt), so a deployer cannot squat a client's address), and a guardian-only, one-wayrevokeForwarderfor clones a leaked deployer key made.monerium_account_registrations(with waiting reason, last check and deployment send time); 087monerium_accounts.activated_at.GET /v1/monerium-b2b/registrations(manager key): state,waitingReason, rejection reason; filterable by Monerium profile ID.active, non-dormant accounts swap or forward. A payment before activation waits on the clone with the deposit waiting reasonaccount_not_activeand the deadline refunds it (automatically only withMONERIUM_B2B_AUTO_RECOVERY=auto). Activation recordsactivated_at(dormancy anchor) and every status change is logged; an onboarding account whose destination check fails can be suspended once its IBAN is issued. Onboarding stops polling accounts that have their IBAN.GET /v1/admin/monerium-b2b/accounts(with partner manager,?status=onboardingfor the activation queue),GET /v1/admin/monerium-b2b/registrationswith the keeper's progress,POST /v1/admin/monerium-b2b/registrations/:id/withdrawfor a registration not mapped yet.reviewandclosedparse (they used to throw); list offsets are capped below Postgres' bigint range.Decisions are in
docs/adr-0007-monerium-b2b-partner-registration.md. The in-depth review's 24 confirmed findings are fixed in the follow-up commits, each with a regression test, and an adversarial verification pass confirmed the fixes.Compatibility
POST /v1/monerium-b2b/accounts,GET /v1/monerium-b2b/registrations,GET /v1/admin/monerium-b2b/accounts,GET /v1/admin/monerium-b2b/registrations,POST /v1/admin/monerium-b2b/registrations/:registrationId/withdraw;DepositWaitingReasongainsaccount_not_active(additive). Existing routes and response shapes are unchanged.PATCH /v1/admin/monerium-b2b/accounts/:id/statusnow also acceptsonboarding → suspended(with an IBAN); everything it accepted before behaves as before.onboardingno longer converts. Before deploying to a backend that already holds accounts, listGET /v1/admin/monerium-b2b/accounts?status=onboardingand activate or confirm those with an IBAN (runbook §1.7).MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID(read lowercased) andMONERIUM_B2B_DEPLOYER_PRIVATE_KEY(distinct from the attestor, guardian, keeper and float keys). Without them the endpoint answers 403 and the keeper deploys nothing.Rollout
setKeeper,setDeployer(<deployer>, true)with the guardian key; fund the deployer (runbook §8.5 for Sepolia).PUT /v1/admin/managed-profile-managers/:profileId,EU,business); the partner uses its own secret key.Tests
registration.test.ts: waiting reasons, definite rejections, one deployment per cycle, rotation, re-check interval, resend, revoked clone, stale registry read, error classes (including viem's wrapped revert), operator mapping adoption, races with a withdrawal or a new attempt, sandbox-only activation, the pinned CREATE2 salt.monerium-b2b-registration.integration.test.ts: replay, conflicts, client conflicts, 422/503, races at create, re-registration, impersonation refusal, input validation, paging, manager binding.account_not_activeset and cleared on every queued deposit), dormancy anchor, onboarding re-poll, vars, pagination, and Monerium profile-state tests.bun typecheck,bun verify,bun run wire-contract:check,bun run docs:api:check.Not included
GET /v1/monerium-b2b/registrations.