Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/wide-colts-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ support manteca inquiry
4 changes: 2 additions & 2 deletions server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import authentication from "./auth/authentication";
import registration from "./auth/registration";
import card from "./card";
import kyc from "./kyc";
import onramp from "./onramp";
import passkey from "./passkey";
import pax from "./pax";
import ramp from "./ramp";
import appOrigin from "../utils/appOrigin";

const api = new Hono()
Expand All @@ -24,7 +24,7 @@ const api = new Hono()
.route("/activity", activity)
.route("/card", card)
.route("/kyc", kyc)
.route("/onramp", onramp)
.route("/ramp", ramp)
.route("/passkey", passkey)
.route("/pax", pax);

Expand Down
26 changes: 21 additions & 5 deletions server/api/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getPendingInquiryTemplate,
PANDA_TEMPLATE,
resumeInquiry,
scopeValidationErrors,
} from "../utils/persona";
import publicClient from "../utils/publicClient";
import validatorHook from "../utils/validatorHook";
Expand All @@ -39,7 +40,7 @@ export default new Hono()
object({
templateId: optional(picklist([CRYPTOMATE_TEMPLATE, PANDA_TEMPLATE])), // TODO remove this after deprecate templateId query parameter
countryCode: optional(literal("true")),
scope: optional(picklist(["basic"])),
scope: optional(picklist(["basic", "manteca"])),
}),
validatorHook(),
),
Expand All @@ -58,7 +59,6 @@ export default new Hono()
setUser({ id: account });
setContext("exa", { credential });

Choose a reason for hiding this comment

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

medium

The removal of the eslint-disable-next-line comment is a good improvement. It indicates that the condition if (scope === "basic" && credential.pandaId) is now correctly recognized as potentially necessary, likely due to the introduction of the 'manteca' scope.

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (scope === "basic" && credential.pandaId) {
if (c.req.valid("query").countryCode) {
const personaAccount = await getAccount(credentialId, scope).catch((error: unknown) => {
Expand Down Expand Up @@ -95,7 +95,15 @@ export default new Hono()
return c.json({ code: "legacy kyc", legacy: "legacy kyc" }, 200);
}

const inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope);
let inquiryTemplateId: Awaited<ReturnType<typeof getPendingInquiryTemplate>>;
try {
inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope);
} catch (error: unknown) {
if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) {
return c.json({ code: "not supported" }, 400);
}
throw error;
}
if (!inquiryTemplateId) return c.json({ code: "ok", legacy: "ok" }, 200);
const inquiry = await getInquiry(credentialId, inquiryTemplateId);
if (!inquiry) return c.json({ code: "not started", legacy: "kyc not started" }, 400);
Expand Down Expand Up @@ -128,7 +136,7 @@ export default new Hono()
"json",
object({
redirectURI: optional(string()),
scope: optional(picklist(["basic"])),
scope: optional(picklist(["basic", "manteca"])),
templateId: optional(string()), // TODO remove this after deprecate templateId query parameter
}),
validatorHook({ debug }),
Expand All @@ -146,7 +154,15 @@ export default new Hono()
setUser({ id: parse(Address, credential.account) });
setContext("exa", { credential });

const inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope);
let inquiryTemplateId: Awaited<ReturnType<typeof getPendingInquiryTemplate>>;
try {
inquiryTemplateId = await getPendingInquiryTemplate(credentialId, scope);
} catch (error: unknown) {
if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) {
return c.json({ code: "not supported" }, 400);
}
throw error;
}
if (!inquiryTemplateId) {
return c.json({ code: "already approved", legacy: "kyc already approved" }, 400);
}
Expand Down
124 changes: 49 additions & 75 deletions server/api/onramp.ts → server/api/ramp.ts

Choose a reason for hiding this comment

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

🟡 Duplicate call to getMantecaDepositDetails in quote endpoint

In the /quote endpoint's manteca case, getMantecaDepositDetails is called twice with the same arguments - once inside the try block (line 150) and once outside (line 158). The second call on line 158 is redundant and wasteful.

Click to expand

Code flow

Lines 149-159 in server/api/ramp.ts:

try {
  depositInfo = getMantecaDepositDetails(query.currency, mantecaUser.exchange);
} catch (error) {
  // error handling...
}
depositInfo = getMantecaDepositDetails(query.currency, mantecaUser.exchange);
return c.json({ quote: await getMantecaQuote(`USDC_${query.currency}`), depositInfo });

If the first call (line 150) succeeds, depositInfo is assigned. Then line 158 immediately overwrites it by calling the exact same function with the same arguments. This is unnecessary duplication.

Impact

This causes unnecessary computation and could potentially throw an error on the second call that wasn't caught by the try-catch block.

(Refers to line 158)

Recommendation: Remove the duplicate call on line 158. The depositInfo variable is already assigned in the try block on line 150.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
import { CryptoNetwork, type DepositDetails, type ProviderInfo, type RampProvider } from "../utils/ramps/shared";
import validatorHook from "../utils/validatorHook";

const debug = createDebug("exa:onramp");
const debug = createDebug("exa:ramp");
Object.assign(debug, { inspectOpts: { depth: undefined } });

const ErrorCodes = {
Expand Down Expand Up @@ -92,37 +92,24 @@ export default new Hono()

const redirectURL = c.req.valid("query").redirectURL;
const [mantecaProvider, bridgeProvider] = await Promise.all([
getMantecaProvider(credential.account, credentialId, templateId, countryCode, redirectURL).catch(
(error: unknown) => {
captureException(error, { contexts: { credential, params: { templateId, countryCode } } });
return { status: "NOT_AVAILABLE" as const, currencies: [], cryptoCurrencies: [], pendingTasks: [] };
},
),
getMantecaProvider(credential.account, countryCode).catch((error: unknown) => {
captureException(error, { contexts: { credential, params: { templateId, countryCode } } });
return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" as const };
}),
getBridgeProvider({
credentialId,
templateId,
customerId: credential.bridgeId,
countryCode,
redirectURL,
}).catch((error: unknown) => {
captureException(error, { contexts: { credential, params: { templateId, countryCode } } });
return { status: "NOT_AVAILABLE" as const, currencies: [], cryptoCurrencies: [], pendingTasks: [] };
return { onramp: { currencies: [], cryptoCurrencies: [] }, status: "NOT_AVAILABLE" as const };
}),
]);

const providers: Record<(typeof RampProvider)[number], InferInput<typeof ProviderInfo>> = {
manteca: {
status: mantecaProvider.status,
currencies: mantecaProvider.currencies,
cryptoCurrencies: mantecaProvider.cryptoCurrencies,
pendingTasks: mantecaProvider.pendingTasks,
},
bridge: {
status: bridgeProvider.status,
currencies: bridgeProvider.currencies,
cryptoCurrencies: bridgeProvider.cryptoCurrencies,
pendingTasks: bridgeProvider.pendingTasks,
},
manteca: mantecaProvider,
bridge: bridgeProvider,
};
return c.json({ providers });
})
Expand Down Expand Up @@ -196,62 +183,49 @@ export default new Hono()
}
}
})
.post(
"/onboarding",
auth(),
vValidator("query", object({ templateId: optional(string()) }), validatorHook({ code: "bad query" })),
vValidator("json", Onboarding, validatorHook({ code: "bad onboarding" })),
async (c) => {
const { credentialId } = c.req.valid("cookie");
const onboarding = c.req.valid("json");
const templateId = c.req.valid("query").templateId ?? PANDA_TEMPLATE;
if (templateId !== PANDA_TEMPLATE) {
return c.json({ code: "bad template", legacy: "invalid persona template" }, 400);
}
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { account: true, bridgeId: true },
});
if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400);
setUser({ id: credential.account });
.post("/", auth(), vValidator("json", Onboarding, validatorHook({ code: "bad onboarding" })), async (c) => {
const { credentialId } = c.req.valid("cookie");
const onboarding = c.req.valid("json");
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { account: true, bridgeId: true },
});
if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400);
setUser({ id: credential.account });

switch (onboarding.provider) {
case "manteca":
try {
await mantecaOnboarding(credential.account, credentialId, templateId);
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(MantecaErrorCodes).includes(error.message)) {
switch (error.message) {
case MantecaErrorCodes.COUNTRY_NOT_ALLOWED:
case MantecaErrorCodes.ID_NOT_ALLOWED:
case MantecaErrorCodes.BAD_KYC_ADDITIONAL_DATA:
return c.json({ code: error.message }, 400);
}
switch (onboarding.provider) {
case "manteca":
try {
await mantecaOnboarding(credential.account, credentialId);
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(MantecaErrorCodes).includes(error.message)) {
switch (error.message) {
case MantecaErrorCodes.NO_DOCUMENT:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
case "bridge":
try {
await bridgeOnboarding({
credentialId,
customerId: credential.bridgeId,
templateId,
acceptedTermsId: onboarding.acceptedTermsId,
});
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) {
switch (error.message) {
case BridgeErrorCodes.ALREADY_ONBOARDED:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
case "bridge":
try {
await bridgeOnboarding({
credentialId,
customerId: credential.bridgeId,
acceptedTermsId: onboarding.acceptedTermsId,
});
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) {
switch (error.message) {
case BridgeErrorCodes.ALREADY_ONBOARDED:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
}
return c.json({ code: "ok" });
},
);
throw error;
}
break;
}
Comment on lines +196 to +229
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where MantecaErrorCodes is defined
fd -t f "manteca" server/ | head -20
rg -l "MantecaErrorCodes" server/ --type ts

Repository: exactly/exa

Length of output: 127


🏁 Script executed:

#!/bin/bash
# Get MantecaErrorCodes definition
rg -A 20 "export.*MantecaErrorCodes" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Get BridgeErrorCodes definition
rg -A 20 "export.*BridgeErrorCodes" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Check mantecaOnboarding function to understand error throwing behavior
rg -B 5 -A 30 "function mantecaOnboarding|export.*mantecaOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 2884


🏁 Script executed:

#!/bin/bash
# Check bridgeOnboarding function to understand error throwing behavior
rg -B 5 -A 30 "function bridgeOnboarding|export.*bridgeOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Find ErrorCodes definition
rg -B 5 -A 30 "export.*ErrorCodes|const.*ErrorCodes" server/ -t ts | head -100

Repository: exactly/exa

Length of output: 5703


🏁 Script executed:

#!/bin/bash
# Search for NO_DOCUMENT error
rg "NO_DOCUMENT" server/ -t ts -B 2 -A 2

Repository: exactly/exa

Length of output: 1729


🏁 Script executed:

#!/bin/bash
# Search for bridgeOnboarding definition
rg -B 5 -A 30 "bridgeOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 3174


🏁 Script executed:

#!/bin/bash
# Check if MantecaErrorCodes is imported or defined in ramp.ts
rg "MantecaErrorCodes|BridgeErrorCodes" server/api/ramp.ts -B 5 -A 5

Repository: exactly/exa

Length of output: 3350


🏁 Script executed:

#!/bin/bash
# Check context around error handling in onboarding endpoint
cat -n server/api/ramp.ts | sed -n '185,235p'

Repository: exactly/exa

Length of output: 2116


🏁 Script executed:

#!/bin/bash
# Look for comments explaining error handling strategy
rg -B 10 "case \"manteca\":" server/api/ramp.ts | head -30

Repository: exactly/exa

Length of output: 959


🏁 Script executed:

#!/bin/bash
# Check if onboarding endpoint is called from webhooks or other places
rg "onboarding|POST /" server/hooks/ -t ts -B 2 -A 2

Repository: exactly/exa

Length of output: 37


Handle all error codes consistently in onboarding endpoint.

The manteca and bridge onboarding error handling only catches specific error codes (NO_DOCUMENT for manteca, ALREADY_ONBOARDED for bridge) and returns 400, while other expected error codes like MANTECA_USER_INACTIVE, NO_PERSONA_ACCOUNT, and NOT_SUPPORTED_CHAIN_ID are re-thrown as 500 errors. This is inconsistent with the GET /details endpoint, which returns 400 for any error in MantecaErrorCodes or BridgeErrorCodes. All errors thrown by mantecaOnboarding and bridgeOnboarding are user-facing validation failures and should return 400 status.

🤖 Prompt for AI Agents
In `@server/api/ramp.ts` around lines 196 - 229, The onboarding handlers only
return 400 for one specific code each; update the error handling in the manteca
and bridge cases (around mantecaOnboarding and bridgeOnboarding) so that any
error whose message is a member of MantecaErrorCodes or BridgeErrorCodes returns
c.json({ code: error.message }, 400). Keep the existing captureException(error,
{ contexts: { credential } }) and the instanceof Error check, replace the
single-case switch with a general check using
Object.values(MantecaErrorCodes).includes(error.message) and
Object.values(BridgeErrorCodes).includes(error.message) and return 400 for any
match instead of re-throwing.

return c.json({ code: "ok" });
});
32 changes: 20 additions & 12 deletions server/hooks/manteca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ const Payload = variant("event", [
event: literal("USER_ONBOARDING_UPDATE"),
data: UserOnboardingUpdateData,
}),
object({
event: literal("USER_STATUS_UPDATE"),
data: unknown(),
}),
object({
event: literal("WITHDRAW_STATUS_UPDATE"),
data: WithdrawStatusUpdateData,
Expand All @@ -156,21 +160,25 @@ export default new Hono().post(
async (c) => {
const payload = c.req.valid("json");

if (payload.event === "USER_STATUS_UPDATE") {
return c.json({ code: "deprecated" }, 200);
}

if (payload.event === "SYSTEM_NOTICE") {
captureEvent({ message: "MANTECA SYSTEM NOTICE", contexts: { payload } });
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}

if (payload.event === "COMPLIANCE_NOTICE") {
// TODO evaluate send a push notification
captureEvent({ message: "MANTECA COMPLIANCE NOTICE", contexts: { payload } });
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}

if (payload.event === "PAYMENT_REFUND") {
// TODO retrieve the userExternalId from manteca to continue with the flow
captureEvent({ message: "MANTECA PAYMENT REFUND", contexts: { payload } });
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}

const user = await database.query.credentials.findFirst({
Expand All @@ -179,30 +187,30 @@ export default new Hono().post(
});
if (!user) {
captureException(new Error("user not found"), { contexts: { payload } });
return c.json({ code: "user not found", status: 200 });
return c.json({ code: "user not found" }, 200);
}

switch (payload.event) {
case "DEPOSIT_DETECTED":
await handleDepositDetected(payload.data, user.account);
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
case "ORDER_STATUS_UPDATE":
if (payload.data.status === "CANCELLED") {
captureException(new Error("order cancelled"), { contexts: { payload } });
await convertBalanceToUsdc(payload.data.userNumberId, payload.data.against);
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}
if (payload.data.status === "COMPLETED") {
await withdrawBalance(payload.data.userNumberId, payload.data.asset, user.account);
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
case "WITHDRAW_STATUS_UPDATE":
if (payload.data.status === "CANCELLED") {
await withdrawBalance(payload.data.userNumberId, payload.data.asset, user.account);
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
case "USER_ONBOARDING_UPDATE":
if (payload.data.user.status === "ACTIVE") {
sendPushNotification({
Expand All @@ -211,9 +219,9 @@ export default new Hono().post(
contents: { en: "Your fiat onramp account has been activated" },
}).catch((error: unknown) => captureException(error));
}
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
default:
return c.json({ code: "ok" });
return c.json({ code: "ok" }, 200);
}
},
);
Expand Down
Loading