Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ebdb66f
docs(repo): propose dual Monerium app support
ebma Sep 14, 2026
33d12a0
feat(shared): add a user-token Monerium client
ebma Sep 14, 2026
0b9af06
feat(api): resolve Monerium identity through either app
ebma Sep 14, 2026
1acb209
feat(api): select the Monerium OAuth callback per client
ebma Sep 14, 2026
03b84d2
feat(api): link Monerium wallets and report EUR ramp readiness
ebma Sep 14, 2026
6fa61b5
feat(kyc): expose Monerium ramp readiness and wallet operations
ebma Sep 14, 2026
1b4c4aa
feat(dashboard): link Monerium wallets and enable EUR pay-ins
ebma Sep 14, 2026
2d1c46c
feat(kyc): let a Monerium authorization be re-checked from the redirect
ebma Sep 14, 2026
983c67b
feat(frontend): onboard EUR through Monerium and link the wallet
ebma Sep 14, 2026
aeb3934
feat(sdk): register the Monerium EUR onramp and return its owner permit
ebma Sep 15, 2026
e1ec97a
docs(api): document EUR onramps through the SDK, dashboard, and widget
ebma Sep 15, 2026
989e342
docs(repo): record the dual Monerium app decision and boundary
ebma Sep 15, 2026
d235a64
fix(api): reject unsupported EUR onramp destinations with a public qu…
ebma Sep 15, 2026
6132997
fix(dashboard): hide destinations the EUR onramp cannot serve
ebma Sep 15, 2026
6b8ab69
fix(frontend): hide Polygon for EUR pay-ins and translate the quote r…
ebma Sep 15, 2026
6e07211
fix(dashboard): let an approved Monerium row win over a legacy Mykobo…
ebma Sep 15, 2026
959ec28
fix(kyc): route a lost Monerium session back to authorization and all…
ebma Sep 15, 2026
771e4ac
fix(frontend): bound the Monerium IBAN wait and keep the OAuth panel …
ebma Sep 15, 2026
59a10bd
fix(frontend): track the Monerium onramp phases on the progress ring
ebma Sep 15, 2026
f952bd3
fix(api): resolve the Monerium binding across the profile's entities
ebma Sep 15, 2026
9a6c935
fix(api): refuse a second live Monerium ramp or an IBAN move for an o…
ebma Sep 15, 2026
d775933
fix(api): give the Monerium permit the swap deadline
ebma Sep 15, 2026
54fedce
fix(api): pause a Monerium ramp whose permit died without leaving an …
ebma Sep 15, 2026
8956115
fix(api): evict a Monerium session whose refresh grant is rejected
ebma Sep 15, 2026
ac27d26
fix(sdk): fail loudly on a mismatched EUR walletAddress and keep the …
ebma Sep 15, 2026
34b93a5
docs(repo): reconcile the EUR onramp integration notes with the shipp…
ebma Sep 15, 2026
795c155
test(api): add the hermetic Monerium EUR onramp corridor scenario
ebma Sep 15, 2026
002c3f4
test(api): cover the Uniswap route rejections and swap failure branches
ebma Sep 15, 2026
f72f44e
docs(api): note that the Monerium issue fee must stay zero until it i…
ebma Sep 15, 2026
fded533
fix(repo): protect Monerium ramps and confirm IBAN moves
ebma Sep 15, 2026
7586c46
fix(dashboard): let Monerium reconnect outrank the wallet-link hint
ebma Sep 16, 2026
b1e2a6d
docs(api): state the one-week Monerium permit horizon in the EUR corr…
ebma Sep 16, 2026
c67fed1
feat(shared): let the EUR onramp deliver to Polygon
ebma Sep 16, 2026
97ad3e4
feat(api): add the same-chain Polygon Monerium onramp flow
ebma Sep 16, 2026
b581958
feat(dashboard): offer Polygon again for EUR pay-ins
ebma Sep 16, 2026
bf2baee
docs(repo): document the same-chain Polygon EUR onramp route
ebma Sep 16, 2026
fee9393
fix(shared): cap EUR pay-ins at 500 EUR while the Polygon EURe pool i…
ebma Sep 17, 2026
1a830c3
feat(api): make the Monerium white-label credentials optional
ebma Sep 17, 2026
430aa0e
feat(api): add EUR_ONRAMP_ENABLED kill switch for new EUR pay-in quotes
ebma Sep 17, 2026
476131f
docs(repo): document the EUR kill switch and optional white-label cre…
ebma Sep 17, 2026
6fe82f7
feat(api): route the Polygon EURe swap through USDC.e
ebma Sep 17, 2026
4477dee
fix(shared): raise the EUR pay-in cap to 5,000 EUR on the two-hop route
ebma Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions .agents/skills/vortex-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in
- **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent.
- **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes.
- **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.)
- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU; these support BUY and SELL. The backend additionally supports direct-API EUR/SEPA BUY for pre-provisioned approved users. The SDK does not support that owner-permit journey, and EUR SELL is unavailable. These corridors deliver to EVM networks only (no AssetHub).
- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA (Monerium) supports BUY only. EUR BUY needs `walletAddress` (the user's Monerium-linked wallet, linked in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`. Supply `customerType` to select the same individual or business Monerium profile used at onboarding; it is required when both types are bound (`MONERIUM_CUSTOMER_TYPE_REQUIRED` otherwise). `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect Monerium first. These corridors deliver to EVM networks only (no AssetHub).
- **EUR currency value**: TypeScript uses the member `FiatToken.EURC`, which serializes to the wire value `"EUR"`. Raw JSON clients must send `"EUR"`, with `"sepa"` as the rail identifier.
- **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the authenticated profile. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations.
- **Deferred offramp funding**: the SDK checks the source wallet balance at `registerRamp` by default. Server integrations that register before funding a temporary wallet may configure `offrampFundingMode: "deferred"`. This skips only the SDK pre-flight; fund the exact `walletAddress` before signing/submitting user transactions, then update and start before the registration window expires. Backend execution-time balance checks remain authoritative.
Expand Down Expand Up @@ -265,23 +265,42 @@ triggers:
```

## When to use
The user wants to buy crypto with EUR and has already been provisioned as corridor-ready: an approved Vortex EUR provider binding, a live approved provider profile, exactly one existing Polygon EOA/IBAN destination, and access to that EOA for typed-data signing. Both individual and business legal entities may qualify. The active route delivers only to supported non-Polygon EVM destinations.
The user wants to buy crypto with EUR and is already corridor-ready: an approved Vortex EUR provider binding, a live approved provider profile, exactly one existing Polygon EOA/IBAN destination, and access to that EOA for typed-data signing. Both individual and business legal entities may qualify. The active route delivers to supported EVM destinations, Polygon included.

Do not use this flow for onboarding, wallet linking, IBAN provisioning, or EUR SELL. Those operations are unavailable in the active product integration.
Users become corridor-ready by completing Monerium OAuth onboarding in the Dashboard or Widget and linking the wallet they will pay in with (`POST /v1/monerium/wallet`); this flow does not cover onboarding, wallet linking, or IBAN provisioning. EUR SELL is unavailable.

## Prerequisites
- Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported non-Polygon EVM destination.
- Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported EVM destination.
- A secret credential or Supabase session for the corridor-ready legal entity.
- `additionalData.destinationAddress`; do not submit profile, Monerium address, or IBAN identity.
- `additionalData.customerType` (`"individual"` or `"business"`) when the user owns both legal profiles; use the same type as onboarding and wallet linking.
- A fresh EVM ephemeral key and a wallet-signing channel for the profile-linked Polygon owner.

## SDK recipe
```js
// walletAddress must be the wallet linked to the Monerium profile; a mismatch throws EurOnrampError.
const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, {
customerType: "individual",
destinationAddress: "0xDestinationWallet",
walletAddress: "0xMoneriumLinkedWallet"
});

// unsignedTransactions holds the owner's EIP-712 permit; the ephemeral txs are signed by the SDK.
const updated = await vortex.submitUserTransactions(rampProcess.id, unsignedTransactions, {
signTypedData: payload => signTypedData(wagmiConfig, payload)
});

// updated.ibanPaymentData (IBAN, BIC, receiver name, reference) is released once every signature
// validates; show it verbatim, have the user pay by SEPA, then start before the deadline.
await vortex.startRamp(rampProcess.id);
```

## Direct API sequence

The current `@vortexfi/sdk` EUR handler is not compatible with this flow because it drops the
owner-wallet permit. Use the raw API:
Raw API clients perform the same steps themselves:

1. Create the EUR BUY quote.
2. Call `POST /v1/ramp/register` with the quote ID, fresh EVM signing account, and destination address.
2. Call `POST /v1/ramp/register` with the quote ID, fresh EVM signing account, destination address, and `additionalData.customerType` when needed.
3. Partition every returned `unsignedTx` by `signer`. Sign ephemeral-owned raw transactions with the
ephemeral key. Send the EIP-712 `moneriumOnrampSelfTransfer` permit to the linked owner wallet.
4. Submit the complete signed set to `POST /v1/ramp/update`. Partial updates are accepted, but
Expand All @@ -290,11 +309,12 @@ owner-wallet permit. Use the raw API:
initiate the SEPA transfer, then call `POST /v1/ramp/start` before the start deadline.
6. Poll status or use Vortex webhooks for the ramp lifecycle.

The permit expires 24 hours after preparation. If SEPA settlement arrives after expiry or the owner
consumes its nonce, automatic execution can stop for manual resolution.
The permit expires one week after preparation. If SEPA settlement arrives after expiry or the owner
consumes its nonce, automatic execution stops for manual resolution.

## Common failures
- `400` approved-profile error: the effective legal entity has no approved local Monerium/EUR binding or the live provider profile is not approved.
- `409 MONERIUM_CUSTOMER_TYPE_REQUIRED`: both legal types are bound; repeat registration with the type used for wallet linking.
- `409` expected-one-destination error: the profile does not have exactly one matching Polygon EOA/IBAN destination. Vortex does not create, select, or move one in this release.
- Contract-wallet error: the linked mint destination must be an EOA for the ERC-2612 handoff.
- Missing payment instructions after register: expected; submit every owner and ephemeral signature through update first.
Expand Down Expand Up @@ -675,7 +695,7 @@ try {

## Current corridor reality (August 2026)
- **BRL via PIX**: onramp and offramp both live. `taxId` deprecated — derived from the user-linked key.
- **EUR via SEPA**: BUY is active through the direct backend API (`FiatToken.EURC`, rail `"sepa"`) for an already-bound approved user with one Polygon EOA/IBAN destination. It requires the linked owner's typed-data permit. The SDK, Widget, and Dashboard do not yet implement that journey. SELL is unavailable.
- **EUR via SEPA**: BUY is active (`FiatToken.EURC`, rail `"sepa"`) for an approved Monerium user with one Polygon EOA/IBAN destination, through the SDK (`walletAddress` + `submitUserTransactions` for the owner permit), the Widget, the Dashboard, and the direct API. Onboarding and wallet linking happen in the Dashboard or Widget. Destinations: any supported EVM network. SELL is unavailable.
- **USD (ACH) / MXN (SPEI) / COP (ACH) / ARS (CBU)**: onramp and offramp live via the AlfredPay corridor; registration requires an authenticated user identity. Route resolver determines availability per-combination.
- Live corridors deliver to EVM networks; AssetHub ramp execution is currently disabled.

Expand Down
7 changes: 6 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,13 @@ ALFREDPAY_API_SECRET=your-alfredpay-api-secret
MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id
MONERIUM_API_URL=https://api.monerium.dev
MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback
# Exact widget callback registered with Monerium; leave unset to disable the widget OAuth flow.
MONERIUM_WIDGET_REDIRECT_URI=http://localhost:5173/widget
# Set to false to stop quoting new EUR pay-ins (registered ramps keep executing).
EUR_ONRAMP_ENABLED=true
# Server-to-server white-label access (shared client; also the Monerium B2B onramp
# credentials). Keep this backend-only.
# credentials). Keep this backend-only. Optional: when unset, every Monerium read goes
# through the user's OAuth session instead.
MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id
MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret
# Required in production. Flat EUR fee subtracted from each Monerium issue quote.
Expand Down
75 changes: 72 additions & 3 deletions apps/api/src/api/controllers/monerium.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import { APIError } from "../errors/api-error";
import { completeMoneriumOAuth, getMoneriumStatus, startMoneriumOAuth } from "../services/monerium/monerium.service";
import {
completeMoneriumOAuth,
getMoneriumStatus,
MONERIUM_OAUTH_CLIENTS,
MONERIUM_REAUTHENTICATION_REQUIRED,
type MoneriumOAuthClient,
startMoneriumOAuth
} from "../services/monerium/monerium.service";
import { getMoneriumRampReadiness, linkMoneriumWallet, moveMoneriumIban } from "../services/monerium/wallet";

type CustomerType = "individual" | "business";

Expand All @@ -12,6 +20,21 @@ function customerType(value: unknown): CustomerType {
return value;
}

function optionalCustomerType(value: unknown): CustomerType | undefined {
return value === undefined ? undefined : customerType(value);
}

function oauthClient(value: unknown): MoneriumOAuthClient {
if (value === undefined) return "dashboard";
if (!MONERIUM_OAUTH_CLIENTS.includes(value as MoneriumOAuthClient)) {
throw new APIError({
message: `client must be one of: ${MONERIUM_OAUTH_CLIENTS.join(", ")}`,
status: httpStatus.BAD_REQUEST
});
}
return value as MoneriumOAuthClient;
}

function requiredString(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
throw new APIError({ message: `${name} is required`, status: httpStatus.BAD_REQUEST });
Expand All @@ -36,7 +59,9 @@ export async function start(req: Request, res: Response, next: NextFunction): Pr
) {
throw new APIError({ message: "email must match the authenticated user", status: httpStatus.BAD_REQUEST });
}
res.status(httpStatus.OK).json(await startMoneriumOAuth(user.userId, user.email, customerType(body.customerType)));
res
.status(httpStatus.OK)
.json(await startMoneriumOAuth(user.userId, user.email, customerType(body.customerType), oauthClient(body.client)));
} catch (error) {
next(error);
}
Expand All @@ -57,7 +82,51 @@ export async function complete(req: Request, res: Response, next: NextFunction):
export async function status(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const user = authenticatedUser(req);
res.status(httpStatus.OK).json(await getMoneriumStatus(user.userId, customerType(req.query.customerType)));
const result = await getMoneriumStatus(user.userId, customerType(req.query.customerType));
if (result.status !== "APPROVED") {
res.status(httpStatus.OK).json(result);
return;
}
// Readiness needs a live read; a persisted approval stays readable when the OAuth session is gone.
try {
res.status(httpStatus.OK).json({ ...result, ramp: await getMoneriumRampReadiness(user.userId, result.customerType) });
} catch (error) {
if (!(error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED)) throw error;
res.status(httpStatus.OK).json({ ...result, rampError: { code: error.type, message: error.message } });
}
} catch (error) {
next(error);
}
}

export async function linkWallet(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const user = authenticatedUser(req);
const body = (req.body ?? {}) as Record<string, unknown>;
res.status(httpStatus.OK).json(
await linkMoneriumWallet(user.userId, {
address: body.address,
chain: body.chain,
customerType: optionalCustomerType(body.customerType),
signature: body.signature
})
);
} catch (error) {
next(error);
}
}
Comment on lines +102 to +117

export async function moveIban(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const user = authenticatedUser(req);
const body = (req.body ?? {}) as Record<string, unknown>;
res.status(httpStatus.OK).json(
await moveMoneriumIban(user.userId, {
address: body.address,
chain: body.chain,
customerType: optionalCustomerType(body.customerType)
})
);
} catch (error) {
next(error);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/api/controllers/onboarding.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../services/avenia/avenia-kyc-import.service";
import { selectActiveCustomerEntity } from "../services/customer-entity.service";
import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../services/monerium/monerium.service";
import { getMoneriumRampReadiness, type MoneriumRampReadiness } from "../services/monerium/wallet";

// Provider status refreshes piggyback on the dashboard's 15s status poll; cap them per customer so
// polling (and multiple open tabs) doesn't hammer the providers. Marking at check time also dedupes
Expand Down Expand Up @@ -128,6 +129,7 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise<
}
}
const providerErrors = new Map<string, { code: string; message: string }>();
const rampReadiness = new Map<string, MoneriumRampReadiness>();

await Promise.all(
providerCustomers
Expand All @@ -146,14 +148,21 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise<
: VerificationStatus.InReview
);
customer.set("statusExternal", refreshed.statusExternal);
if (refreshed.status === "APPROVED") {
rampReadiness.set(customer.id, await getMoneriumRampReadiness(userId, customer.customerType));
}
} catch (error) {
if (error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED) {
providerErrors.set(customer.id, {
code: MONERIUM_REAUTHENTICATION_REQUIRED,
message: error.message
});
return;
}
// Status aggregation remains available if Monerium is unavailable or in-memory credentials were lost.
logger.warn(
`Monerium status refresh failed for provider customer ${customer.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
})
);
Expand Down Expand Up @@ -368,6 +377,7 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise<
: null,
provider: customer.provider,
rail: customer.rail,
ramp: rampReadiness.get(customer.id) ?? null,
state: customer.status,
status: customer.status,
statusExternal: customer.statusExternal,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/api/routes/v1/monerium.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ router.use(requireAuth);
router.post("/oauth/start", rejectImpersonation, moneriumController.start);
router.post("/oauth/complete", rejectImpersonation, moneriumController.complete);
router.get("/status", moneriumController.status);
router.post("/wallet", rejectImpersonation, moneriumController.linkWallet);
router.post("/iban/move", rejectImpersonation, moneriumController.moveIban);

export default router;
49 changes: 49 additions & 0 deletions apps/api/src/api/services/monerium/active-ramp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Op, type Transaction } from "sequelize";
import sequelize from "../../../config/database";
import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../constants/constants";
import RampState from "../../../models/rampState.model";

/**
* The Monerium ramp, if any, that can still mint to and pull from `owner`: every non-terminal
* ramp except an unstarted one whose start window has already closed. Two such ramps would share
* the owner's ERC-2612 nonce and race for the same SEPA credit, and an IBAN move under one would
* redirect the mint it is waiting for.
*/
export async function findActiveMoneriumRampForOwner(owner: string, transaction?: Transaction): Promise<string | null> {
const ramp = await RampState.findOne({
attributes: ["id"],
...(transaction ? { transaction } : {}),
where: {
[Op.and]: [
sequelize.where(
sequelize.fn("lower", sequelize.literal("state->'blockState'->'moneriumIssue'->>'owner'")),
owner.toLowerCase()
),
{ currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut"] } },
{
[Op.or]: [
{ currentPhase: { [Op.ne]: "initial" } },
{ createdAt: { [Op.gt]: new Date(Date.now() - RAMP_START_EXPIRATION_TIME_SECONDS * 1000) } }
]
}
]
}
});
return ramp?.id ?? null;
}

/** Hold this through registration's ramp insert or an IBAN move's provider mutation. */
export async function lockMoneriumProfile(profileId: string, transaction: Transaction): Promise<void> {
await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", {
replacements: { key: `monerium:profile:${profileId}` },
transaction
});
}

/** Serialize ramps sharing the EOA's balance baseline and permit nonce, even across profiles. */
export async function lockMoneriumOwner(owner: string, transaction: Transaction): Promise<void> {
await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", {
replacements: { key: `monerium:owner:${owner.toLowerCase()}` },
transaction
});
}
Loading
Loading