diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 44c914024..54172be7c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -51,6 +51,60 @@ console.log("Please do the pix transfer using the following code: ", depositQrCo const startedRamp = await sdk.startRamp(rampProcess.id); ``` +### Alfredpay (USD / MXN / COP) onramp + +```typescript +import { VortexSdk, FiatToken, EvmToken, Networks, RampDirection } from "@vortexfi/sdk"; + +const sdk = new VortexSdk({ apiBaseUrl: "http://localhost:3000" }); + +const quote = await sdk.createQuote({ + inputAmount: "100", + inputCurrency: FiatToken.COP, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon +}); + +const { rampProcess } = await sdk.registerRamp(quote, { + destinationAddress: "0x1234567890123456789012345678901234567890", + fiatAccountId: "", + walletAddress: "0x1234567890123456789012345678901234567890" +}); + +// Inspect off-chain fiat payment instructions before starting. +const startedRamp = await sdk.startRamp(rampProcess.id); +console.log("Pay via:", startedRamp.achPaymentData); +``` + +### Alfredpay (USD / MXN / COP) offramp + +```typescript +const quote = await sdk.createQuote({ + from: Networks.Polygon, + inputAmount: "10", + inputCurrency: EvmToken.USDC, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL +}); + +const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + fiatAccountId: "", + walletAddress: "0x1234567890123456789012345678901234567890" +}); + +// Sign and submit the user-side EVM transactions (squidRouter approve/swap, etc.) +// then push the resulting hashes back to Vortex: +const updated = await sdk.updateRamp(quote, rampProcess.id, { + squidRouterApproveHash: "0x...", + squidRouterSwapHash: "0x..." +}); + +const startedRamp = await sdk.startRamp(updated.id); +``` + +> `fiatAccountId` is opaque to the SDK. Consumers create or look up the user's Alfredpay fiat account out-of-band (via the Vortex backend) and pass the ID in. + ## Core Features - **Ephemerals abstracted**: No need to keep track of the ephemeral accounts used in the ramp process. If `storeEphemeralKeys` is enabled, keys are stored in a JSON file in Node.js. - **Stateless Design**: No internal state management - you control persistence of the rampId for status checking diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index c96f938bc..4d5a08ad9 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -6,6 +6,7 @@ import { createStellarEphemeral, EphemeralAccount, EphemeralAccountType, + isAlfredpayToken, QuoteResponse, RampDirection, RampProcess, @@ -13,11 +14,15 @@ import { UnsignedTx } from "@vortexfi/shared"; import { TransactionSigningError } from "./errors"; +import { AlfredpayHandler } from "./handlers/AlfredpayHandler"; import { BrlHandler } from "./handlers/BrlHandler"; import { ApiService } from "./services/ApiService"; import { NetworkManager } from "./services/NetworkManager"; import { storeEphemeralKeys } from "./storage"; import type { + AlfredpayOfframpAdditionalData, + AlfredpayOfframpUpdateAdditionalData, + AlfredpayOnrampAdditionalData, BrlOfframpAdditionalData, BrlOfframpUpdateAdditionalData, BrlOnrampAdditionalData, @@ -32,6 +37,7 @@ export class VortexSdk { private publicKey: string | undefined; private networkManager: NetworkManager; private brlHandler: BrlHandler; + private alfredpayHandler: AlfredpayHandler; private initializationPromise: Promise; private storeEphemeralKeys: boolean; @@ -48,6 +54,13 @@ export class VortexSdk { this.signTransactions.bind(this) ); + this.alfredpayHandler = new AlfredpayHandler( + this.apiService, + this, + this.generateEphemerals.bind(this), + this.signTransactions.bind(this) + ); + this.initializationPromise = this.networkManager.waitForInitialization(); } @@ -86,7 +99,13 @@ export class VortexSdk { let unsignedTransactions: UnsignedTx[] = []; if (quote.rampType === RampDirection.BUY) { - if (quote.from === "pix") { + if (isAlfredpayToken(quote.inputCurrency)) { + rampProcess = await this.alfredpayHandler.registerAlfredpayOnramp( + quote.id, + additionalData as AlfredpayOnrampAdditionalData + ); + unsignedTransactions = []; + } else if (quote.from === "pix") { rampProcess = await this.brlHandler.registerBrlOnramp(quote.id, additionalData as BrlOnrampAdditionalData); unsignedTransactions = []; } else if (quote.from === "sepa") { @@ -95,7 +114,11 @@ export class VortexSdk { throw new Error(`Unsupported onramp from: ${quote.from}`); } } else if (quote.rampType === RampDirection.SELL) { - if (quote.to === "pix") { + if (isAlfredpayToken(quote.outputCurrency)) { + const offrampData = additionalData as AlfredpayOfframpAdditionalData; + rampProcess = await this.alfredpayHandler.registerAlfredpayOfframp(quote.id, offrampData); + unsignedTransactions = await this.getUserTransactions(rampProcess, offrampData.walletAddress); + } else if (quote.to === "pix") { rampProcess = await this.brlHandler.registerBrlOfframp(quote.id, additionalData as BrlOfframpAdditionalData); const userAddress = (additionalData as BrlOfframpAdditionalData).walletAddress; unsignedTransactions = await this.getUserTransactions(rampProcess, userAddress); @@ -117,13 +140,20 @@ export class VortexSdk { additionalUpdateData: UpdateRampAdditionalData ): Promise { if (quote.rampType === RampDirection.BUY) { - if (quote.from === "pix") { + if (isAlfredpayToken(quote.inputCurrency)) { + throw new Error("Alfredpay onramp does not require any further data"); + } else if (quote.from === "pix") { throw new Error("Brl onramp does not require any further data"); } else if (quote.from === "sepa") { throw new Error("Euro onramp handler not implemented yet"); } } else if (quote.rampType === RampDirection.SELL) { - if (quote.to === "pix") { + if (isAlfredpayToken(quote.outputCurrency)) { + return this.alfredpayHandler.updateAlfredpayOfframp( + rampId, + additionalUpdateData as AlfredpayOfframpUpdateAdditionalData + ); + } else if (quote.to === "pix") { return this.brlHandler.updateBrlOfframp(rampId, additionalUpdateData as BrlOfframpUpdateAdditionalData); } else if (quote.to === "sepa") { throw new Error("Euro offramp handler not implemented yet"); diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index c111618bb..4bb279ae5 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -137,6 +137,36 @@ export class InvalidPixKeyError extends BrlOfframpError { } } +// Alfredpay Onramp specific errors +export class AlfredpayOnrampError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "AlfredpayOnrampError"; + } +} + +export class MissingAlfredpayOnrampParametersError extends AlfredpayOnrampError { + constructor() { + super("Parameters destinationAddress and fiatAccountId are required for Alfredpay onramp", 400); + this.name = "MissingAlfredpayOnrampParametersError"; + } +} + +// Alfredpay Offramp specific errors +export class AlfredpayOfframpError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "AlfredpayOfframpError"; + } +} + +export class MissingAlfredpayOfframpParametersError extends AlfredpayOfframpError { + constructor() { + super("Parameters fiatAccountId and walletAddress are required for Alfredpay offramp", 400); + this.name = "MissingAlfredpayOfframpParametersError"; + } +} + // Monerium specific errors export class MoneriumError extends RegisterRampError { constructor(message: string, status = 400) { @@ -362,6 +392,9 @@ export function parseAPIError(response: any): VortexSdkError { if (errorMessage === "Invalid pixKey or receiverTaxId") { return new InvalidPixKeyError(); } + if (errorMessage === "Parameter destinationAddress is required for Alfredpay onramp") { + return new MissingAlfredpayOnrampParametersError(); + } if (errorMessage === "Parameters moneriumAuthToken and destinationAddress are required for Monerium onramp") { return new MissingMoneriumOnrampParametersError(); } diff --git a/packages/sdk/src/handlers/AlfredpayHandler.ts b/packages/sdk/src/handlers/AlfredpayHandler.ts new file mode 100644 index 000000000..1febfb44c --- /dev/null +++ b/packages/sdk/src/handlers/AlfredpayHandler.ts @@ -0,0 +1,151 @@ +import { + AccountMeta, + EphemeralAccount, + EphemeralAccountType, + RampProcess, + RegisterRampRequest, + UnsignedTx, + UpdateRampRequest +} from "@vortexfi/shared"; +import { MissingAlfredpayOfframpParametersError, MissingAlfredpayOnrampParametersError } from "../errors"; +import type { ApiService } from "../services/ApiService"; +import type { + AlfredpayOfframpAdditionalData, + AlfredpayOfframpUpdateAdditionalData, + AlfredpayOnrampAdditionalData, + RampHandler, + VortexSdkContext +} from "../types"; + +export class AlfredpayHandler implements RampHandler { + private apiService: ApiService; + private context: VortexSdkContext; + private generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>; + private signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + stellarEphemeral?: EphemeralAccount; + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise; + + constructor( + apiService: ApiService, + context: VortexSdkContext, + generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>, + signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + stellarEphemeral?: EphemeralAccount; + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise + ) { + this.apiService = apiService; + this.context = context; + this.generateEphemerals = generateEphemerals; + this.signTransactions = signTransactions; + } + + async registerAlfredpayOnramp(quoteId: string, additionalData: AlfredpayOnrampAdditionalData): Promise { + if (!additionalData.destinationAddress || !additionalData.fiatAccountId) { + throw new MissingAlfredpayOnrampParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + destinationAddress: additionalData.destinationAddress, + fiatAccountId: additionalData.fiatAccountId, + sessionId: additionalData.sessionId, + walletAddress: additionalData.walletAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const signedTxs = await this.signTransactions(rampProcess.unsignedTxs || [], { + evmEphemeral: ephemerals.EVM, + stellarEphemeral: ephemerals.Stellar, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async registerAlfredpayOfframp(quoteId: string, additionalData: AlfredpayOfframpAdditionalData): Promise { + if (!additionalData.fiatAccountId || !additionalData.walletAddress) { + throw new MissingAlfredpayOfframpParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + fiatAccountId: additionalData.fiatAccountId, + sessionId: additionalData.sessionId, + walletAddress: additionalData.walletAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const signedTxs = await this.signTransactions(rampProcess.unsignedTxs || [], { + evmEphemeral: ephemerals.EVM, + stellarEphemeral: ephemerals.Stellar, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async updateAlfredpayOfframp(rampId: string, additionalData: AlfredpayOfframpUpdateAdditionalData): Promise { + const rampProcess = await this.apiService.getRampStatus(rampId); + if (rampProcess.currentPhase !== "initial") { + throw new Error( + `Invalid ramp id. Ramp must be on initial phase to be updated. Current phase: ${rampProcess.currentPhase}` + ); + } + + const updateRequest: UpdateRampRequest = { + additionalData: { + assethubToPendulumHash: additionalData.assethubToPendulumHash, + squidRouterApproveHash: additionalData.squidRouterApproveHash, + squidRouterSwapHash: additionalData.squidRouterSwapHash + }, + presignedTxs: [], + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 81c7cc700..5a1b6a762 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -26,7 +26,15 @@ export { EvmTransactionData }; -export type AnyQuote = BrlOnrampQuote | EurOnrampQuote | BrlOfframpQuote | EurOfframpQuote; +export type AnyQuote = + | BrlOnrampQuote + | EurOnrampQuote + | AlfredpayOnrampQuote + | BrlOfframpQuote + | EurOfframpQuote + | AlfredpayOfframpQuote; + +export type AlfredpayCurrency = FiatToken.USD | FiatToken.MXN | FiatToken.COP; export type BrlOnrampQuote = QuoteResponse & { rampType: RampDirection.BUY; @@ -38,6 +46,11 @@ export type EurOnrampQuote = QuoteResponse & { from: "sepa"; }; +export type AlfredpayOnrampQuote = QuoteResponse & { + rampType: RampDirection.BUY; + inputCurrency: AlfredpayCurrency; +}; + export type BrlOfframpQuote = QuoteResponse & { rampType: RampDirection.SELL; to: "pix"; @@ -48,31 +61,46 @@ export type EurOfframpQuote = QuoteResponse & { to: "sepa"; }; +export type AlfredpayOfframpQuote = QuoteResponse & { + rampType: RampDirection.SELL; + outputCurrency: AlfredpayCurrency; +}; + export type ExtendedQuoteResponse = T extends { rampType: RampDirection.BUY; from: "pix" } ? BrlOnrampQuote : T extends { rampType: RampDirection.BUY; from: "sepa" } ? EurOnrampQuote - : T extends { rampType: RampDirection.SELL; to: "pix" } - ? BrlOfframpQuote - : T extends { rampType: RampDirection.SELL; to: "sepa" } - ? EurOfframpQuote - : AnyQuote; + : T extends { rampType: RampDirection.BUY; inputCurrency: AlfredpayCurrency } + ? AlfredpayOnrampQuote + : T extends { rampType: RampDirection.SELL; to: "pix" } + ? BrlOfframpQuote + : T extends { rampType: RampDirection.SELL; to: "sepa" } + ? EurOfframpQuote + : T extends { rampType: RampDirection.SELL; outputCurrency: AlfredpayCurrency } + ? AlfredpayOfframpQuote + : AnyQuote; export type AnyAdditionalData = | BrlOfframpAdditionalData | EurOfframpAdditionalData + | AlfredpayOfframpAdditionalData | BrlOnrampAdditionalData - | EurOnrampAdditionalData; + | EurOnrampAdditionalData + | AlfredpayOnrampAdditionalData; export type RegisterRampAdditionalData = Q extends BrlOnrampQuote ? BrlOnrampAdditionalData : Q extends EurOnrampQuote ? EurOnrampAdditionalData - : Q extends BrlOfframpQuote - ? BrlOfframpAdditionalData - : Q extends EurOfframpQuote - ? EurOfframpAdditionalData - : AnyAdditionalData; + : Q extends AlfredpayOnrampQuote + ? AlfredpayOnrampAdditionalData + : Q extends BrlOfframpQuote + ? BrlOfframpAdditionalData + : Q extends EurOfframpQuote + ? EurOfframpAdditionalData + : Q extends AlfredpayOfframpQuote + ? AlfredpayOfframpAdditionalData + : AnyAdditionalData; export interface BrlOnrampAdditionalData { destinationAddress: string; @@ -83,6 +111,13 @@ export interface EurOnrampAdditionalData { moneriumAuthToken: string; } +export interface AlfredpayOnrampAdditionalData { + destinationAddress: string; + fiatAccountId: string; + walletAddress?: string; + sessionId?: string; +} + export interface BrlOfframpAdditionalData { pixDestination: string; receiverTaxId: string; @@ -95,20 +130,31 @@ export interface EurOfframpAdditionalData { walletAddress: string; } +export interface AlfredpayOfframpAdditionalData { + fiatAccountId: string; + walletAddress: string; + sessionId?: string; +} + export type AnyUpdateAdditionalData = | EurOnrampUpdateAdditionalData | BrlOfframpUpdateAdditionalData - | EurOfframpUpdateAdditionalData; + | EurOfframpUpdateAdditionalData + | AlfredpayOfframpUpdateAdditionalData; export type UpdateRampAdditionalData = Q extends BrlOnrampQuote ? never // No additional data required from the user for this type of ramp. : Q extends EurOnrampQuote ? EurOnrampUpdateAdditionalData - : Q extends BrlOfframpQuote - ? BrlOfframpUpdateAdditionalData - : Q extends EurOfframpQuote - ? EurOfframpUpdateAdditionalData - : AnyUpdateAdditionalData; + : Q extends AlfredpayOnrampQuote + ? never // Alfredpay onramp settles fiat off-chain; no user transactions to update. + : Q extends BrlOfframpQuote + ? BrlOfframpUpdateAdditionalData + : Q extends EurOfframpQuote + ? EurOfframpUpdateAdditionalData + : Q extends AlfredpayOfframpQuote + ? AlfredpayOfframpUpdateAdditionalData + : AnyUpdateAdditionalData; export interface EurOnrampUpdateAdditionalData { squidRouterApproveHash: string; @@ -127,6 +173,12 @@ export interface EurOfframpUpdateAdditionalData { assethubToPendulumHash?: string; } +export interface AlfredpayOfframpUpdateAdditionalData { + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; + assethubToPendulumHash?: string; +} + export interface BrlKycResponse { evmAddress: string; kycLevel: number;