Fix Monerium flow after API change#1157
Conversation
✅ 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. |
…s and same-chain passthrough handling
There was a problem hiding this comment.
Pull request overview
This PR updates the Monerium onramp flow to align with upstream API/behavior changes, improving correctness and safety around permit signing/validation, self-transfer execution, and EVM token resolution/logging.
Changes:
- Add richer Monerium permit payloads (signed context) on the frontend and enforce server-side permit validation (EIP-712 recovery + strict field checks).
- Harden the Monerium self-transfer phase with preflight inspection/simulation and persistence of the self-transfer tx hash to avoid resends.
- Improve EVM operational behavior: redact RPC URLs in logs and resolve EVM token details by contract address (including Monerium EURe Polygon v1/v2).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shared/src/tokens/utils/helpers.ts | Adds getEvmTokenDetailsByAddress and a Monerium EURe address fallback to resolve tokens by contract address. |
| packages/shared/src/services/evm/clientManager.ts | Redacts RPC URLs in logs and tightens readContractWithRetry typings/casting for viem. |
| packages/shared/src/endpoints/monerium.ts | Extends Monerium endpoint types: IBAN now includes name; permit signature now supports an optional signed context. |
| apps/frontend/src/helpers/crypto.ts | Returns PermitSignature with a context payload when signing ERC-2612 permits. |
| apps/api/src/api/services/transactions/onramp/common/monerium.ts | Increases Monerium self-transfer gas limit via a named constant. |
| apps/api/src/api/services/ramp/ramp.service.ts | Uses ibanData.name for EPC QR/IBAN payment data and validates Monerium onramp permits against quote/state expectations. |
| apps/api/src/api/services/ramp/monerium-self-transfer.ts | New helper to parse/inspect a presigned transferFrom raw transaction before broadcast. |
| apps/api/src/api/services/ramp/monerium-permit.ts | New helper to validate permit context + signature recovery and decide if sending the permit is necessary. |
| apps/api/src/api/services/phases/meta-state-types.ts | Adds moneriumOnrampSelfTransferHash to persisted phase metadata. |
| apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts | Uses address-based token resolution and skips squid routing for same-chain same-token passthrough. |
| apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts | Adds permit preflight/optional send, self-transfer preflight validation, and stores transfer hash for idempotency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (network === Networks.Polygon && MONERIUM_EURE_POLYGON_ADDRESSES.has(normalizedTokenAddress)) { | ||
| return { | ||
| assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, | ||
| decimals: ERC20_EURE_POLYGON_DECIMALS, | ||
| erc20AddressSourceChain: tokenAddress, | ||
| isNative: false, | ||
| network: Networks.Polygon, | ||
| pendulumRepresentative: PENDULUM_USDC_AXL, | ||
| type: TokenType.Evm | ||
| }; |
| if (currentNonce > transfer.signedNonce) { | ||
| throw new Error( | ||
| `[${rampId}] Self-transfer signed nonce ${transfer.signedNonce} has already been consumed by ${transfer.signer} (current nonce ${currentNonce}). Do not resend this raw transaction; regenerate the presigned self-transfer transaction or inspect the previous nonce-${transfer.signedNonce} transaction.` |
| export function validateMoneriumOnrampPermit(permit: PermitSignature, expectation: MoneriumPermitExpectation): void { | ||
| const context = getPermitContext(permit); | ||
| const expectedChainId = getNetworkId(expectation.network); | ||
|
|
||
| assertEqual("owner", context.owner, expectation.expectedOwner); | ||
| assertEqual("spender", context.spender, expectation.expectedSpender); | ||
| assertEqual("valueRaw", context.valueRaw, expectation.expectedValueRaw); | ||
| assertEqual("tokenAddress", context.tokenAddress, expectation.expectedTokenAddress); | ||
| assertEqual("tokenName", context.tokenName, expectation.expectedTokenName); | ||
| assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); | ||
| assertEqual("chainId", context.chainId, expectedChainId); | ||
| assertEqual("deadline", context.deadline, permit.deadline); | ||
|
|
||
| const recoveredSigner = verifyTypedData( | ||
| { | ||
| chainId: context.chainId, | ||
| name: context.tokenName, | ||
| verifyingContract: context.tokenAddress, | ||
| version: context.tokenVersion | ||
| }, | ||
| PERMIT_TYPES, | ||
| { | ||
| deadline: context.deadline, | ||
| nonce: context.nonce, | ||
| owner: context.owner, | ||
| spender: context.spender, | ||
| value: context.valueRaw | ||
| }, | ||
| EvmSignature.from({ r: permit.r, s: permit.s, v: permit.v }).serialized | ||
| ); | ||
|
|
||
| if (recoveredSigner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { | ||
| throwBadPermit(`Monerium permit signature was produced by ${recoveredSigner}, expected ${expectation.expectedOwner}`); | ||
| } | ||
| } |
| export async function inspectMoneriumSelfTransferTransaction( | ||
| txData: string, | ||
| expectation: MoneriumSelfTransferExpectation | ||
| ): Promise<MoneriumSelfTransferInspection> { | ||
| const serializedTransaction = txData as RecoverableSerializedTransaction; | ||
| const parsedTx = parseTransaction(serializedTransaction); | ||
| const signer = await recoverTransactionAddress({ serializedTransaction }); | ||
| const tokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; | ||
| const signedNonce = parsedTx.nonce; | ||
|
|
||
| if (signedNonce === undefined) { | ||
| throw new Error(`[${expectation.rampId}] Self-transfer signed transaction is missing a nonce`); | ||
| } | ||
|
|
||
| if (signer.toLowerCase() !== expectation.expectedSigner.toLowerCase()) { | ||
| throw new Error( | ||
| `[${expectation.rampId}] Self-transfer signer ${signer} does not match expected EVM ephemeral ${expectation.expectedSigner}` | ||
| ); | ||
| } | ||
|
|
||
| if (parsedTx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { | ||
| throw new Error(`[${expectation.rampId}] Self-transfer token ${parsedTx.to} does not match expected ${tokenAddress}`); | ||
| } | ||
|
|
||
| const decodedTransfer = decodeFunctionData({ | ||
| abi: moneriumTransferFromAbi, | ||
| data: parsedTx.data ?? "0x" | ||
| }); | ||
| const [owner, recipient, amountRaw] = decodedTransfer.args; | ||
| const expectedAmount = BigInt(expectation.expectedAmountRaw); | ||
|
|
||
| if (owner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { | ||
| throw new Error( | ||
| `[${expectation.rampId}] Self-transfer owner ${owner} does not match expected ${expectation.expectedOwner}` | ||
| ); | ||
| } | ||
| if (recipient.toLowerCase() !== expectation.expectedRecipient.toLowerCase()) { | ||
| throw new Error( | ||
| `[${expectation.rampId}] Self-transfer recipient ${recipient} does not match expected ${expectation.expectedRecipient}` | ||
| ); | ||
| } | ||
| if (amountRaw !== expectedAmount) { | ||
| throw new Error( | ||
| `[${expectation.rampId}] Self-transfer amount ${amountRaw.toString()} does not match expected ${expectation.expectedAmountRaw}` | ||
| ); | ||
| } | ||
|
|
- Strict nonce equality check with directional error messages to surface both stale and gap-ahead signed nonces on the EVM ephemeral. - Validate chainId of the signed self-transfer against Polygon to reject transactions signed for the wrong network before broadcast. - Reject Monerium permits with a missing signed context explicitly instead of silently coercing undefined to a string mismatch. - Document the 30s post-broadcast settlement wait. - Add unit tests for permit validation, self-transfer inspection, squid-router same-chain passthrough, monerium tx builders, shared EVM client manager redaction, and EURE token helper fallbacks.
ebma
left a comment
There was a problem hiding this comment.
Independent Code Review
Reviewed all 11 modified files against staging. Below: my own findings + verdicts on the Copilot review (#pullrequestreview-4338359773). Accepted improvements have been pushed as commit 99710cd60.
Verdicts on Copilot's Comments
1. Extract "EURe" symbol to a constant — Skip. The literal has a single consumer (squidRouterPhaseHandler balance check) and no UI/display surface. Adding a shared constant for a single internal comparison would be over-engineering (CLAUDE.md "No Over-Engineering").
2. Nonce comparison treats gap-ahead as okay — Accepted. The original current > signed silently proceeded when the current nonce was greater than signed (treating it as already-included) but did not flag the inverse case (signed nonce ahead of current). On an ephemeral account, a gap-ahead signed nonce stalls the mempool indefinitely. Changed to strict !== with directional error messages.
3. Missing chainId validation on signed self-transfer — Accepted. A self-transfer presigned on the wrong chain would otherwise pass inspection. Added optional expectedChainId to MoneriumSelfTransferExpectation and pass getNetworkId(Networks.Polygon) from the handler. Tests added for both positive and negative cases.
4. assertEqual undefined coercion in permit context — Accepted. Previously String(undefined) === "undefined" would produce a confusing "undefined does not match expected X" error. Now throws an explicit "missing from signed context" error. Test added for the entirely-missing-context branch.
My Independent Findings
Accepted & applied (in commit 99710cd60):
- Same as Copilot #2, #3, #4 above
- Added explanatory comment for the 30s
setTimeoutpost-broadcast wait (CLAUDE.md: always commentsetTimeoutrationale)
Considered but skipped:
- Same-chain passthrough balance gate —
squidRouterPhaseHandlerreturns early without an explicit balance check, but the downstreamdestinationTransferphase enforces this. Adding a duplicate check would be over-engineering. - Infura URL redaction in error messages — Already covered by
sanitizeRpcErrorMessageinclientManager.ts. - Address/name casing nits — Cosmetic only.
Test Coverage Added
Tests committed for the affected areas:
monerium-permit.test.ts— value/version mismatch, missing context, nonce drift, allowance-sufficient skipmonerium-self-transfer.test.ts— amount mismatch, chainId positive + negativesquid-router-phase-handler.test.ts,monerium.test.ts,clientManager.test.ts,helpers.test.ts
Verification
- 10/10 monerium tests pass (
bun test) bun typecheckshows no new errors introduced by these edits (4 pre-existing branch errors remain; pre-existing on06ca1a5a9before any of my changes)bun lint:fixclean on touched files
Note on mykobo-eur-offramp.integration.test.ts
This untracked file references several exports that don't exist in @vortexfi/shared (MykoboApiService, MykoboCurrency, etc.) and RampPhase values that aren't defined. It appears to be WIP for a separate feature. Not committed as part of this PR — recommend handling separately.
| export function redactRpcUrlForLogs(rpcUrl: string): string { | ||
| if (!rpcUrl) { | ||
| return "<default>"; | ||
| } | ||
|
|
| it("redacts provider API keys from RPC URLs", () => { | ||
| expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/dUzb7oLgJ3f9T72vWR-Iw7X38wct7h62")).toBe( | ||
| "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" | ||
| ); |
| assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); | ||
| assertEqual("chainId", context.chainId, expectedChainId); | ||
| assertEqual("deadline", context.deadline, permit.deadline); | ||
|
|
No description provided.