Skip to content

Let the partner register a client's destination and onboard it automatically - #1408

Merged
ebma merged 36 commits into
stagingfrom
feat/monerium-b2b-destination-endpoint
Oct 8, 2026
Merged

ebma merged 36 commits into
stagingfrom
feat/monerium-b2b-destination-endpoint

Conversation

@ebma

@ebma ebma commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Merge with "Squash and merge" (squash merging is disabled in the repository settings; enable it for this merge).

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 cast and 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 by MONERIUM_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), 409 MONERIUM_B2B_DESTINATION_CONFLICT for any difference or a profile that already has an account, 409 MONERIUM_B2B_CLIENT_CONFLICT when the reference or email belongs to another client, 422 when Monerium does not know the profile in the app or rejected or closed it, 503 MONERIUM_B2B_PROVIDER_UNAVAILABLE when Monerium fails (nothing recorded). No KYB data.
  • Keeper step (registration.ts): waits until Monerium reports the profile approved, deploys the clone with a dedicated deployer key, then maps it through the existing verified provisionMoneriumB2bAccount. 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 a waitingReason the 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.
  • Factory (redeploy needed, the Sepolia one is replaced): guardian-managed deployer role (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-way revokeForwarder for clones a leaked deployer key made.
  • Migrations: 086 monerium_account_registrations (with waiting reason, last check and deployment send time); 087 monerium_accounts.activated_at.
  • GET /v1/monerium-b2b/registrations (manager key): state, waitingReason, rejection reason; filterable by Monerium profile ID.
  • Activation gates conversion: only active, non-dormant accounts swap or forward. A payment before activation waits on the clone with the deposit waiting reason account_not_active and the deadline refunds it (automatically only with MONERIUM_B2B_AUTO_RECOVERY=auto). Activation records activated_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.
  • Admin (ADMIN_SECRET): GET /v1/admin/monerium-b2b/accounts (with partner manager, ?status=onboarding for the activation queue), GET /v1/admin/monerium-b2b/registrations with the keeper's progress, POST /v1/admin/monerium-b2b/registrations/:id/withdraw for a registration not mapped yet.
  • Also: Monerium profile states review and closed parse (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

  • Wire-contract snapshot: added routes 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; DepositWaitingReason gains account_not_active (additive). Existing routes and response shapes are unchanged.
  • PATCH /v1/admin/monerium-b2b/accounts/:id/status now also accepts onboarding → suspended (with an IBAN); everything it accepted before behaves as before.
  • Behaviour change: an account still in onboarding no longer converts. Before deploying to a backend that already holds accounts, list GET /v1/admin/monerium-b2b/accounts?status=onboarding and activate or confirm those with an IBAN (runbook §1.7).
  • New env vars are optional and only valid together: MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID (read lowercased) and MONERIUM_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

  1. Deploy the factory and vault from this branch; setKeeper, setDeployer(<deployer>, true) with the guardian key; fund the deployer (runbook §8.5 for Sepolia).
  2. Set both env vars on the B2B backend (runbook §8.6 for the sandbox, rollout checklist for mainnet).
  3. Make the partner's profile a manager (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.
  • Admin, executor (account_not_active set and cleared on every queued deposit), dormancy anchor, onboarding re-poll, vars, pagination, and Monerium profile-state tests.
  • Forge: salt binding, squatting, deployer role and its limits, guardian transfer, revoke.
  • Gates: API suite 2247 pass / 0 fail, forge 88 pass, bun typecheck, bun verify, bun run wire-contract:check, bun run docs:api:check.

Not included

  • Reading the IBAN and the payer IBAN back from Monerium (V7).
  • Per-partner Monerium credentials (rest of V8): one bound partner for the pilot.
  • A webhook when a registration is rejected; the partner sees it in GET /v1/monerium-b2b/registrations.
  • An age-based alert for accounts awaiting activation; operators use the admin list.

ebma added 8 commits October 1, 2026 19:49
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.
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.
@netlify

netlify Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

Name Link
🔨 Latest commit 13dce08
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6ac7bacfa48e0200081598db

@netlify

netlify Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

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

@netlify

netlify Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 13dce08
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6ac7bacf6861700008c642ae
😎 Deploy Preview https://deploy-preview-1408--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.

ebma added 6 commits October 7, 2026 10:31
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.
@ebma ebma changed the title Let SulPayments register a client's destination and onboard it automatically Let the partner register a client's destination and onboard it automatically Oct 7, 2026
… feat/monerium-b2b-destination-endpoint

# Conflicts:
#	docs/operations-monerium-b2b-runbook.md
#	docs/product-monerium-b2b-flow.md
@ebma
ebma force-pushed the feat/monerium-b2b-destination-endpoint branch from 6ae673f to 09c9730 Compare October 7, 2026 16:03
ebma added 10 commits October 7, 2026 18:41
…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.
ebma added 7 commits October 7, 2026 19:20
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.
ebma added 4 commits October 8, 2026 17:35
…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.
@ebma
ebma merged commit a60dad5 into staging Oct 8, 2026
6 checks passed
@ebma
ebma deleted the feat/monerium-b2b-destination-endpoint branch October 8, 2026 16:31
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