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/swift-bridges-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
Comment thread
mainqueg marked this conversation as resolved.
---

✨ add bridge offramp
269 changes: 255 additions & 14 deletions server/api/ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ import * as manteca from "../utils/ramps/manteca";
import validatorHook from "../utils/validatorHook";

const ErrorCodes = {
EXTERNAL_ACCOUNT_CURRENCY_MISMATCH: "external account currency mismatch",
EXTERNAL_ACCOUNT_NOT_FOUND: "external account not found",
EXTERNAL_ACCOUNT_NOT_SUPPORTED: "external account not supported",
INVALID_DEPOSIT_ADDRESS: "invalid deposit address",
NO_CREDENTIAL: "no credential",
NOT_APPROVED: "not approved",
NOT_STARTED: "not started",
WITHDRAWAL_IN_PROGRESS: "withdrawal in progress",
};

export default new Hono()
Expand Down Expand Up @@ -74,7 +80,11 @@ export default new Hono()
})
.catch((error: unknown) => {
captureException(error, { level: "error", contexts: { credential, params: { countryCode } } });
return { onramp: { currencies: [] }, status: "NOT_AVAILABLE" as const };
return {
onramp: { currencies: [] },
offramp: { currencies: [] },
status: "NOT_AVAILABLE" as const,
};
}),
]);

Expand All @@ -93,13 +103,62 @@ export default new Hono()
vValidator(
"query",
variant("provider", [
object({ provider: literal("manteca"), currency: picklist(manteca.Currency) }),
object({ provider: literal("bridge"), currency: picklist(bridge.FiatCurrency) }),
object({ provider: literal("bridge"), currency: literal("USDT"), network: literal("TRON") }),
object({
currency: picklist(manteca.Currency),
direction: optional(literal("onramp")),
provider: literal("manteca"),
}),
object({
currency: picklist(bridge.FiatCurrency),
direction: optional(literal("onramp")),
provider: literal("bridge"),
}),
object({
currency: picklist(bridge.FiatCurrency),
direction: literal("offramp"),
externalAccountId: string(),
provider: literal("bridge"),
}),
object({
address: Address,
currency: literal("USDC"),
direction: literal("offramp"),
network: literal("BASE"),
provider: literal("bridge"),
}),
object({
address: string(),
currency: literal("USDC"),
direction: literal("offramp"),
network: literal("SOLANA"),
provider: literal("bridge"),
}),
object({
address: string(),
currency: literal("USDC"),
direction: literal("offramp"),
memo: string(),
network: literal("STELLAR"),
provider: literal("bridge"),
}),
object({
address: string(),
currency: literal("USDT"),
direction: literal("offramp"),
network: literal("TRON"),
provider: literal("bridge"),
}),
object({
currency: literal("USDT"),
direction: optional(literal("onramp")),
network: literal("TRON"),
provider: literal("bridge"),
}),
object({
currency: literal("USDC"),
direction: optional(literal("onramp")),
network: picklist([...bridge.EVMNetwork, "SOLANA", "STELLAR"]),
provider: literal("bridge"),
}),
]),
validatorHook(),
Expand All @@ -115,11 +174,11 @@ export default new Hono()
const account = parse(Address, credential.account);
setUser({ id: account });

let depositInfo: InferOutput<typeof DepositDetails>[];
switch (query.provider) {
case "manteca": {
const mantecaUser = await manteca.getUser(account);
if (!mantecaUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
let depositInfo: InferOutput<typeof DepositDetails>[];
try {
depositInfo = manteca.getDepositDetails(query.currency, mantecaUser.exchange);
} catch (error) {
Expand All @@ -144,18 +203,80 @@ export default new Hono()
if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
const bridgeUser = await bridge.getCustomer(credential.bridgeId);
if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);
const quote = (await bridge.getQuote("USD", query.currency)) satisfies QuoteResponse;
Comment thread
mainqueg marked this conversation as resolved.

if (query.direction === "offramp") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ("network" in query) {
try {
return c.json(
{
quote,
depositInfo: await bridge.getCryptoOfframpDepositDetails(
query.currency,
query.network,
query.address,
parse(Address, credential.account),
bridgeUser,
query.network === "STELLAR" ? query.memo : undefined,
),
},
200,
);
} catch (error) {
if (error instanceof Error && error.message === bridge.ErrorCodes.INVALID_DEPOSIT_ADDRESS) {
return c.json({ code: ErrorCodes.INVALID_DEPOSIT_ADDRESS }, 400);
}
throw error;
}
}
try {
return c.json(
{
quote,
depositInfo: await bridge.getOfframpDepositDetails(
query.externalAccountId,
credential.account,
bridgeUser,
query.currency,
),
},
200,
);
} catch (error) {
if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) {
return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400);
}
if (error instanceof Error && error.message === bridge.ErrorCodes.NOT_AVAILABLE_CURRENCY) {
return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_SUPPORTED }, 400);
}
if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH) {
return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH }, 400);
}
if (error instanceof Error && error.message === bridge.ErrorCodes.TRANSFER_IN_USE) {
return c.json({ code: ErrorCodes.WITHDRAWAL_IN_PROGRESS }, 400);
}
throw error;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

depositInfo = await ("currency" in query && "network" in query
? bridge.getCryptoDepositDetails(query.currency, query.network, credential.account, bridgeUser)
: bridge.getDepositDetails(query.currency, credential.account, bridgeUser));
if ("network" in query) {
return c.json(
{
quote,
depositInfo: await bridge.getCryptoDepositDetails(
query.currency,
query.network,
credential.account,
bridgeUser,
),
},
200,
);
}

return c.json(
{
quote: ("currency" in query && "network" in query
? undefined
: await bridge.getQuote("USD", query.currency)) satisfies QuoteResponse,
depositInfo,
},
{ quote, depositInfo: await bridge.getDepositDetails(query.currency, credential.account, bridgeUser) },
200,
);
}
Expand Down Expand Up @@ -231,6 +352,108 @@ export default new Hono()
}
return c.json({ code: "ok" }, 200);
},
)
.post("/external-account", auth(), vValidator("json", bridge.ExternalAccountInput, validatorHook()), async (c) => {
const { credentialId } = c.req.valid("cookie");
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);
if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
setUser({ id: parse(Address, credential.account) });

const bridgeUser = await bridge.getCustomer(credential.bridgeId);
if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);

try {
return c.json(await bridge.createExternalAccount(bridgeUser, c.req.valid("json")), 200);
} catch (error) {
if (error instanceof Error && error.message === bridge.ErrorCodes.NO_ENDORSEMENT) {
return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);
}
throw error;
}
})
.get("/external-account", auth(), async (c) => {
const { credentialId } = c.req.valid("cookie");
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);
if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
setUser({ id: parse(Address, credential.account) });

const bridgeUser = await bridge.getCustomer(credential.bridgeId);
if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);

return c.json(await bridge.listExternalAccounts(credential.bridgeId), 200);
})
.patch(
"/external-account/:id",
auth(),
vValidator("param", object({ id: string() }), validatorHook()),
vValidator("json", bridge.UpdateExternalAccountInput, validatorHook()),
async (c) => {
const { credentialId } = c.req.valid("cookie");
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);
if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
setUser({ id: parse(Address, credential.account) });

const bridgeUser = await bridge.getCustomer(credential.bridgeId);
if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);
try {
return c.json(
await bridge.updateExternalAccount(bridgeUser, c.req.valid("param").id, c.req.valid("json")),
200,
);
} catch (error) {
if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) {
return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400);
}
throw error;
}
},
)
.delete(
"/external-account/:id",
auth(),
vValidator("param", object({ id: string() }), validatorHook()),
async (c) => {
const { credentialId } = c.req.valid("cookie");
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);
if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
setUser({ id: parse(Address, credential.account) });

const bridgeUser = await bridge.getCustomer(credential.bridgeId);
if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400);
if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400);

try {
await bridge.removeExternalAccount(bridgeUser, c.req.valid("param").id);
} catch (error) {
if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) {
return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400);
}
if (error instanceof Error && error.message === bridge.ErrorCodes.TRANSFER_IN_USE) {
return c.json({ code: ErrorCodes.WITHDRAWAL_IN_PROGRESS }, 400);
}
throw error;
}
return c.json({ code: "ok" }, 200);
},
Comment thread
mainqueg marked this conversation as resolved.
);
Comment thread
mainqueg marked this conversation as resolved.

async function getOrCreateInquiry(credentialId: string, template: string) {
Expand Down Expand Up @@ -325,6 +548,13 @@ const DepositDetails = variant("network", [
estimatedProcessingTime: string(),
}),
),
object({
network: literal("OPTIMISM"),
displayName: literal("Optimism"),
address: Address,
fee: string(),
estimatedProcessingTime: string(),
}),
object({
network: literal("TRON"),
displayName: literal("TRON"),
Expand Down Expand Up @@ -379,6 +609,17 @@ const ProviderInfo = variant("provider", [
}),
object({
provider: literal("bridge"),
offramp: object({
currencies: array(
union([
picklist(bridge.FiatCurrency),
variant("currency", [
object({ currency: literal("USDT"), network: literal("TRON") }),
object({ currency: literal("USDC"), network: picklist(["BASE", "SOLANA", "STELLAR"]) }),
]),
]),
),
}),
onramp: object({
currencies: array(
union([
Expand Down
Loading