Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import RampState from "../../../../models/rampState.model";
import { BasePhaseHandler } from "../base-phase-handler";
import { StateMetadata } from "../meta-state-types";

const ALFREDPAY_POLL_INTERVAL_MS = 5000;
const ALFREDPAY_POLL_INTERVAL_MS = 30000;
const ALFREDPAY_OFFRAMP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes

export class AlfredpayOfframpTransferHandler extends BasePhaseHandler {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,58 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler {
return this.transitionToNextPhase(updatedState, "fundEphemeral");
}

private async waitForUserHash(
state: RampState,
hash: `0x${string}` | undefined,
fromNetwork: EvmNetworks,
label: string,
expectedFrom?: `0x${string}`
): Promise<void> {
if (!hash) {
throw this.createRecoverableError(`${label} hash not yet reported by frontend`);
}
const { publicClient } = this.getExecutorClients(fromNetwork);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (!receipt || receipt.status !== "success") {
throw this.createRecoverableError(`${label} tx failed: ${hash}`);
}
if (expectedFrom && receipt.from.toLowerCase() !== expectedFrom.toLowerCase()) {
throw this.createUnrecoverableError(`${label} tx ${hash} was sent by ${receipt.from}, expected ${expectedFrom}`);
}
logger.info(`${label} tx confirmed: ${hash}`);
}
Comment on lines +117 to +136

private async executeNoPermitFallback(state: RampState, fromNetwork: EvmNetworks): Promise<RampState> {
const expectedFrom = state.state.walletAddress as `0x${string}` | undefined;

if (state.state.isDirectTransfer) {
await this.waitForUserHash(
state,
state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined,
fromNetwork,
"No-permit direct transfer",
expectedFrom
);
} else {
await this.waitForUserHash(
state,
state.state.squidRouterNoPermitApproveHash as `0x${string}` | undefined,
fromNetwork,
"No-permit approve",
expectedFrom
);
await this.waitForUserHash(
state,
state.state.squidRouterNoPermitSwapHash as `0x${string}` | undefined,
fromNetwork,
"No-permit swap",
expectedFrom
);
}

return this.transitionToNextPhase(state, "fundEphemeral");
}

private async executeDirectTransfer(
state: RampState,
signedTypedDataArray: SignedTypedData[],
Expand Down Expand Up @@ -212,6 +264,12 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler {
}

try {
// No-permit fallback: the user submitted the substitute transaction(s) from their own
// wallet during the signing step. We just verify their on-chain success and proceed.
if (state.state.isNoPermitFallback) {
return await this.executeNoPermitFallback(state, fromNetwork);
}

const existingHash = state.state.squidRouterPermitExecutionHash || null;

if (existingHash) {
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/api/services/phases/meta-state-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,11 @@ export interface StateMetadata {
squidRouterPermitExecutionHash?: string;
squidRouterPermitExecutionValue?: string;
isDirectTransfer?: boolean;
// Fallback path used when input ERC20 does not support EIP-2612 permit.
// The user submits the substituting transaction(s) from their own wallet and
// reports back the resulting tx hashes via UpdateRampRequest.additionalData.
isNoPermitFallback?: boolean;
squidRouterNoPermitTransferHash?: string;
squidRouterNoPermitApproveHash?: string;
squidRouterNoPermitSwapHash?: string;
}
46 changes: 37 additions & 9 deletions apps/api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,16 +769,44 @@ export class RampService extends BaseRampService {
}

// To make it harder to extract information, both the pixKey and the receiverTaxId are required to be correct.
// The user-facing error stays generic, but server-side logs differentiate failure modes for diagnosis.
let pixKeyData;
try {
const pixKeyData = await brlaApiService.validatePixKey(pixKey);
//validate the recipient's taxId with partial information
if (!validateMaskedNumber(normalizeTaxId(pixKeyData.taxId), normalizeTaxId(receiverTaxId))) {
throw new APIError({
message: "Invalid pixKey or receiverTaxId.",
status: httpStatus.BAD_REQUEST
});
}
} catch (_error) {
pixKeyData = await brlaApiService.validatePixKey(pixKey);
} catch (error) {
logger.warn(
`validateBrlaOfframpRequest: pix-info lookup failed for pixKey=${pixKey}: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new APIError({
message: "Invalid pixKey or receiverTaxId.",
status: httpStatus.BAD_REQUEST
});
}

let masksMatch: boolean;
try {
// Do NOT pass the masked taxId through normalizeTaxId: that helper strips all
// non-digits, which would also strip the `*` mask characters and break the
// length-aligned comparison done by validateMaskedNumber.
masksMatch = validateMaskedNumber(pixKeyData.taxId, normalizeTaxId(receiverTaxId));
} catch (error) {
logger.warn(
`validateBrlaOfframpRequest: pix key owner taxId is not comparable to receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(
receiverTaxId
)}: ${error instanceof Error ? error.message : String(error)}`
);
throw new APIError({
message: "Invalid pixKey or receiverTaxId.",
status: httpStatus.BAD_REQUEST
});
}

if (!masksMatch) {
logger.warn(
`validateBrlaOfframpRequest: pix key owner taxId does not match receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}`
);
throw new APIError({
message: "Invalid pixKey or receiverTaxId.",
status: httpStatus.BAD_REQUEST
Expand Down
Loading
Loading