Skip to content

Add monerium interactive#731

Merged
gianfra-t merged 63 commits into
stagingfrom
add-monerium-interactive
Jul 7, 2025
Merged

Add monerium interactive#731
gianfra-t merged 63 commits into
stagingfrom
add-monerium-interactive

Conversation

@gianfra-t
Copy link
Copy Markdown
Contributor

@gianfra-t gianfra-t commented Jun 19, 2025

Adds Monerium onramp capabilities.

Main changes

  • Adds a new flow for EUR onramps, including phase handlers and corresponding functions. (backend)
  • Adds transaction creation for the new EUR onramp phases. (backend)
  • Modification of register and update parameters to enable the new route. (backend)
  • New Monerium services that integrate with their API. (backend)
  • Integrates new flow into the UI, allowing for the redirect to Monerium interactive and handling of the call-back to Vortex (front).
  • Modifies function to sign unsigned transactions to support Polygon. (front)

Additional backend changes

  • This PR also introduces a modification to the SquidRouterPayPhaseHandler phase. The fee to be payed will now be calculated based on the implementation found on the AxelarAPI.
  • Adds an RPC manager class to centralize public and wallet clients for EVM chains.
  • Includes better handling (exponential backoff) for waitForTransactionConfirmation on phase squidrouter swap. Solves cases where transaction got included and executed, yet the client couldn't confirm the receipt so quickly.

Tests - Live offramps.

This PR touches much of both the UI and backend logic, including also a potential improvement on how squidrouter-pay estimates the native gas to provide. We should test all routes before merging.

  • EUR onramp to EVM (skipping actual FIAT payment).
  • BRLA onramp to EVM (skipping actual FIAT payment and brla teleport).
  • BRLA onramp to Assethub (skipping actual FIAT payment and brla teleport)..
  • BRL offramp from EVM.
  • EUR offramp from EVM.
  • Eth route (important to check the squidrouter-pay changes).

@netlify
Copy link
Copy Markdown

netlify Bot commented Jun 19, 2025

Deploy Preview for pendulum-pay ready!

Name Link
🔨 Latest commit b140920
🔍 Latest deploy log https://app.netlify.com/projects/pendulum-pay/deploys/686c03222e97fa0008daddfd
😎 Deploy Preview https://deploy-preview-731--pendulum-pay.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread apps/api/src/api/services/monerium/index.ts
Comment thread apps/api/src/api/services/monerium/index.ts Outdated
export const getMoneriumEvmDefaultMintAddress = async (token: string): Promise<string | null> => {
// Assumption is the first linked address is the default mint address for Monerium EVM transactions.
// TODO: this needs to be confirmed.
return getFirstMoneriumLinkedAddress(token);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be confirmed how? By the user? I guess it's okay to assume the first account is the target account. The only problem would be if they lost access to that account some time ago and didn't unlink it. Maybe it would make sense to try and match this to the account that is currently connected on the Vortex UI?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was referring to the question of whether the first account linked (ordered by date) would be the one where the tokens are minted or not.

Even if we match with all linked address, like we do on the function checkAddressExists above, we need to know where the tokens will end up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. And how do we know? Can we decide that somehow? Sorry for the noob question, but asking that is faster than me trying to look it up 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I guess we need to ask someone at Monerium. I couldn't figure it out from the API alone.

Comment thread apps/api/src/api/services/evm/clientManager.ts Outdated
Comment thread apps/api/src/api/services/evm/clientManager.ts Outdated
Comment thread apps/frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx Outdated
const existingLock = localStorage.getItem(lockKey);
if (existingLock !== null) {
console.log(`Process for ${lockKey} already started, skipping...`);
const useSignatureTrace = (traceKey: string) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we touching this logic in the first place?

throw new Error("Cannot proceed with ramp registration, canRegisterRamp is false");
}

// Verify we still own the lock before proceeding
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we removing this check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also answering here. I found that this was causing issues when using Monerium, probably because we must keep the functionality after the redirect/refresh. I agree the name changes were a bit unnecessary, but I tried to simplify the logic.

For this particular case, there was also another lock check on this line which should prevent the execution of the callback anyway. Is this intentional ? Maybe I'm not seeing the use of the second (inner) check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the second (inner) check. If you look at the code, between the initial and the second check, there is a lot of async execution so we need the second check to rule out any issues with race conditions.

Copy link
Copy Markdown
Contributor Author

@gianfra-t gianfra-t Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait but , comparing from staging again, I only see a few if conditions between the two. Hook triggers, then we get the first check, and immediately after, the function registerRampProcess gets called (the rest is the definition of the function itself).
And once inside it, the second check is one of the first operations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since the check of the lock uses the localStorage and not the state of the component, it would still be useful, no?

Comment thread apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts
};

// Helper function to determine if EUR flow should use Monerium
async function shouldRouteToMonerium(executionInput: RampExecutionInput): Promise<boolean> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we make this a non-async function? I guess in the future we'd also determine this without a network call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the idea was to leave it up to the backend. At least originally.

throw new Error("Cannot proceed with ramp registration, canRegisterRamp is false");
}

// Verify we still own the lock before proceeding
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since the check of the lock uses the localStorage and not the state of the component, it would still be useful, no?

Comment thread apps/frontend/src/hooks/ramp/useRampValidation.ts Outdated
Comment thread apps/frontend/src/hooks/useSigningBoxState.ts Outdated
failed: 0,
fundEphemeral: 20,
initial: 0,
moneriumOnrampMint: 60, // TODO we need to profile this value.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah.... good question haha. We can keep it at 60 assuming it's an instant SEPA transfer. For the non-instant one, we need to communicate it differently anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is not relevant anymore, at least not in the non-instant case.

} from "./types";

const MONERIUM_API_URL = "https://api.monerium.app";
export const ERC20_EURE_POLYGON: `0x${string}` = "0x18ec0a6e18e5bc3784fdd3a3634b31245ab704f6"; // EUR.e on Polygon
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use Address type instead of 0x${string}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're still using this type instead of Address across the app right? I think the Address type from viem had some extra properties we don't really need, that's why we don't use it.

}

const data: IbanDataResponse = await response.json();
const ibanData = data.ibans.find(item => item.chain === "polygon");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I know that we know that it must always be Polygon, but it would be useful to leave a comment to explain why we are looking for Polygon here for future reference, wdyt?

throw new Error("Beneficiary name, IBAN, and BIC are required to create EPC QR code data.");
}

const data = ["BCD", "001", "1", "SCT", bic, name, iban, `EUR${amount}`];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could you please leave a comment why the QR Code data looks like it? what is the format exactly, what is the standard we're using here, wdyt?

@@ -0,0 +1,66 @@
export interface MoneriumAddress {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we move these types to a shared package so that they can be used in the front end?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we will need them in the front-end, at least not now. As with BRLA, everything "Monerium" is processed first in the backend.

Comment thread apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts Outdated
Comment thread apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts Outdated
const walletClient = evmClientManager.getWalletClient(Networks.Polygon, fundingAccount);

const txHash = await walletClient.sendTransaction({
to: ephmeralAddress as `0x${string}`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Address instead of 0x${string}`?

});

const receipt = await polygonClient.waitForTransactionReceipt({
hash: txHash as `0x${string}`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Address. instead of 0x${string}`?

tokenValueRaw: string,
swapHash: `0x${string}`,
logIndex: number
): Promise<string> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't say much what do we return here, what exactly is this string? Maybe we can return Promise<Hash>, wdyt?

} else if (quote.inputCurrency === FiatToken.BRL) {
return this.moonbeamClient;
} else {
logger.info(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it 👍 We could also use TS Record to make the code more extensible rather than modifiable like:

Clients: Record<FiatToken, Client> {
[FiatToken.EURC]: this.polygonClient,
[FiatToken.BRL]:  this.moonbeamClient
...
}

return Clients[quote.inputCurrency];

depositQrCode: string | undefined;
// Only used in onramp, offramp - monerium
polygonEphemeralAddress: string;
ibanPaymentData: IbanPaymentData;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pendulum-chain/devs This object begins to become really complex and intuitive. I think we should group it into smaller object-properties, wdyt? (in a separate PR)

import { PendulumTokenDetails, RampCurrency, StellarTokenDetails } from "@packages/shared";
import { ExtrinsicOptions } from "../transactions/nabla";

export interface CoreTransactionData {
  inputAmount: string;
  outputAmount: string;
  inputCurrency: RampCurrency;
  outputCurrency: RampCurrency;
  inputTokenPendulumDetails: PendulumTokenDetails;
  outputTokenPendulumDetails: PendulumTokenDetails;
  outputTokenType: RampCurrency;
  inputAmountBeforeSwapRaw: string;
  // The final step for onramp is the squidRouterSwap or XCM transfer, for offramps it's the anchor payout
  outputAmountBeforeFinalStep: { units: string; raw: string };
}

export interface AddressData {
  pendulumEphemeralAddress: string;
  moonbeamEphemeralAddress: string;
  walletAddress: string | undefined;
  destinationAddress: string;
  brlaEvmAddress: string;
}

export interface TransactionHashes {
  moonbeamXcmTransactionHash: string;
  squidRouterReceiverHash: string;
  pendulumToAssethubXcmHash?: string;
  assetHubToPendulumHash: string;
  squidRouterApproveHash: string;
  squidRouterSwapHash: string;
  squidRouterPayTxHash: string;
  pendulumToMoonbeamXcmHash?: string;
}

export interface StellarData {
  // Only used in offramp - eurc & ars route
  stellarEphemeralAccountId: string;
  stellarTarget: {
    stellarTargetAccountId: string;
    stellarTokenDetails: StellarTokenDetails;
  };
}

export interface BrlaData {
  // Only used in onramp - brla
  inputAmountUnits: string;
  inputAmountBeforeSwapUnits: string;
  taxId: string;
  pixDestination: string;
  receiverTaxId: string;
}

export interface MoneriumData {
  // Only used in onramp, offramp - monerium
  polygonEphemeralAddress: string;
  ibanPaymentData: string;
}

export interface NablaData {
  nablaSoftMinimumOutputRaw: string;
  nabla: {
    approveExtrinsicOptions: ExtrinsicOptions;
    swapExtrinsicOptions: ExtrinsicOptions;
  };
}

export interface SystemState {
  // Only used in offramp
  squidRouterReceiverId: string;
  executeSpacewalkNonce: number;
  unhandledPaymentAlertSent: boolean;
  depositQrCode: string | undefined;
}

export interface StateMetadata {
  core: CoreTransactionData;
  addresses: AddressData;
  hashes: TransactionHashes;
  stellar?: StellarData;
  brla?: BrlaData;
  monerium?: MoneriumData;
  nabla: NablaData;
  system: SystemState;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree with that. Even better, we should try to define different meta-states for our different routes. Otherwise we end up with a very "sparse" state.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely agree!

Comment thread package.json
@@ -1,5 +1,6 @@
{
"dependencies": {
"big.js": "^6.2.1",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this in the root workspace.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need it for the script which is in the root directory?

Comment thread calculate-gas-fee.ts
const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "800000";

function calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string {
console.log("fee response object", feeResponse);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: let's remove?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep this one as this is useful for the script.

}

private calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string {
console.log("fee response object", feeResponse);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: also remove

@gianfra-t gianfra-t merged commit f43211c into staging Jul 7, 2025
5 checks passed
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.

4 participants