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/kind-ants-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🐛 fix chain mock in tests
5 changes: 5 additions & 0 deletions .changeset/lucky-jokes-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ use gcp kms for allower
5 changes: 5 additions & 0 deletions .changeset/silly-yaks-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ poke account after kyc
13 changes: 13 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ services:
- key: DEBUG
scope: RUN_TIME
value: ${{ env.DEBUG }}
- key: GCP_KMS_KEY_RING
scope: RUN_TIME
value: ${{ env.GCP_KMS_KEY_RING }}
- key: GCP_KMS_KEY_VERSION
scope: RUN_TIME
value: ${{ env.GCP_KMS_KEY_VERSION }}
- key: GCP_PROJECT_ID
scope: RUN_TIME
value: ${{ env.GCP_PROJECT_ID }}
- key: GCP_BASE64_JSON
scope: RUN_TIME
type: SECRET
value: ${{ env.ENCRYPTED_GCP_BASE64_JSON || env.GCP_BASE64_JSON }}
- key: INTERCOM_IDENTITY_KEY
scope: RUN_TIME
type: SECRET
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"valibot",
"valierror",
"valkey",
"valora",
"viem",
"viewability",
"wagmi",
Expand Down
426 changes: 425 additions & 1 deletion pnpm-lock.yaml

Large diffs are not rendered by default.

177 changes: 75 additions & 102 deletions server/hooks/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,12 @@ import createDebug from "debug";
import { eq, inArray } from "drizzle-orm";
import { Hono } from "hono";
import * as v from "valibot";
import { bytesToBigInt, withRetry } from "viem";
import { bytesToBigInt } from "viem";

import {
auditorAbi,
exaAccountFactoryAbi,
exaPluginAbi,
exaPreviewerAbi,
exaPreviewerAddress,
marketAbi,
upgradeableModularAccountAbi,
wethAddress,
} from "@exactly/common/generated/chain";
import { Address, Hash } from "@exactly/common/validation";
Expand All @@ -35,6 +31,7 @@ import decodePublicKey from "../utils/decodePublicKey";
import keeper from "../utils/keeper";
import { sendPushNotification } from "../utils/onesignal";
import { autoCredit } from "../utils/panda";
import { pokeAccountAssets } from "../utils/persona";
import publicClient from "../utils/publicClient";
import { track } from "../utils/segment";
import validatorHook from "../utils/validatorHook";
Expand Down Expand Up @@ -107,9 +104,11 @@ export default new Hono().post(
.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi })
.then((p) => new Map<Address, Address>(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)])));
const markets = new Set(marketsByAsset.values());
const pokes = new Map<Address, { assets: Set<Address>; factory: Address; publicKey: Uint8Array<ArrayBuffer> }>();

const accountsToProcess = new Set<Address>();
for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) {
if (!accounts[account]) continue;
// skip notifications for market share transfers
if (rawContract?.address && markets.has(rawContract.address)) continue;
const asset = rawContract?.address ?? ETH;
const underlying = asset === ETH ? WETH : asset;
Expand All @@ -120,112 +119,86 @@ export default new Hono().post(
en: `${value ? `${value} ` : ""}${assetSymbol} received${marketsByAsset.has(underlying) ? " and instantly started earning yield" : ""}`,
},
}).catch((error: unknown) => captureException(error));

if (pokes.has(account)) {
pokes.get(account)?.assets.add(asset);
} else {
const { publicKey, factory } = accounts[account];
pokes.set(account, { publicKey, factory, assets: new Set([asset]) });
}
accountsToProcess.add(account);
}
const { "sentry-trace": sentryTrace, baggage } = getTraceData();
Promise.allSettled(
[...pokes.entries()].map(([account, { publicKey, factory, assets }]) =>
continueTrace({ sentryTrace, baggage }, () =>
withScope((scope) =>
startSpan(
{ name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
async (span) => {
scope.setUser({ id: account });
scope.setTag("exa.account", account);
const isDeployed = !!(await publicClient.getCode({ address: account }));
scope.setTag("exa.new", !isDeployed);
if (!isDeployed) {
try {
await keeper.exaSend(
{ name: "create account", op: "exa.account", attributes: { account } },
{
address: factory,
functionName: "createAccount",
args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
abi: exaAccountFactoryAbi,
},
);
track({ event: "AccountFunded", userId: account });
} catch (error: unknown) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
throw error;
}
}
if (assets.has(ETH)) assets.delete(WETH);
const results = await Promise.allSettled(
[...assets]
.filter((asset) => marketsByAsset.has(asset) || asset === ETH)
.map(async (asset) =>
withRetry(
() =>
keeper.exaSend(
{ name: "poke account", op: "exa.poke", attributes: { account, asset } },
{
address: account,
abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi],
...(asset === ETH
? { functionName: "pokeETH" }
: {
functionName: "poke",
args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion
}),
},
),
[...accountsToProcess]
.map((account) => {
const accountInfo = accounts[account];
if (!accountInfo) return null;
const { publicKey, factory } = accountInfo;
return [account, { publicKey, factory }] as const;
})
.filter((item): item is [Address, { factory: Address; publicKey: Uint8Array<ArrayBuffer> }] => item !== null)
.map(([account, { publicKey, factory }]) =>
continueTrace({ sentryTrace, baggage }, () =>
withScope((scope) =>
startSpan(
{ name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
async (span) => {
scope.setUser({ id: account });
scope.setTag("exa.account", account);
const isDeployed = !!(await publicClient.getCode({ address: account }));
scope.setTag("exa.new", !isDeployed);
if (!isDeployed) {
try {
await keeper.exaSend(
{ name: "create account", op: "exa.account", attributes: { account } },
{
delay: 2000,
retryCount: 5,
shouldRetry: ({ error }) => {
captureException(error, { level: "error" });
return true;
},
address: factory,
functionName: "createAccount",
args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
abi: exaAccountFactoryAbi,
},
),
),
);
for (const result of results) {
if (result.status === "fulfilled") continue;
span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" });
throw result.reason;
}
autoCredit(account)
.then(async (auto) => {
);
track({ event: "AccountFunded", userId: account });
} catch (error: unknown) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
throw error;
}
}
await pokeAccountAssets(account, { ignore: [`NotAllowed(${account})`] }).catch((error: unknown) =>
captureException(error),
);
try {
const auto = await autoCredit(account);
span.setAttribute("exa.autoCredit", auto);
if (!auto) return;
const credential = await database.query.credentials.findFirst({
where: eq(credentials.account, account),
columns: {},
with: {
cards: {
columns: { id: true, mode: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
if (auto) {
const credential = await database.query.credentials.findFirst({
where: eq(credentials.account, account),
columns: {},
with: {
cards: {
columns: { id: true, mode: true },
where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
},
},
},
});
if (!credential || credential.cards.length === 0) return;
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode !== 0) return;
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: { en: "Card mode changed" },
contents: { en: "Credit mode activated" },
}).catch((error: unknown) => captureException(error));
})
.catch((error: unknown) => captureException(error));
span.setStatus({ code: SPAN_STATUS_OK });
},
});
if (credential && credential.cards.length > 0) {
const card = credential.cards[0];
span.setAttribute("exa.card", card?.id);
if (card?.mode === 0) {
await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
span.setAttribute("exa.mode", 1);
sendPushNotification({
userId: account,
headings: { en: "Card mode changed" },
contents: { en: "Credit mode activated" },
}).catch((error: unknown) => captureException(error));
}
}
}
span.setStatus({ code: SPAN_STATUS_OK });
} catch (error: unknown) {
captureException(error);
span.setStatus({ code: SPAN_STATUS_ERROR, message: "autoCredit_failed" });
}
},
),
),
),
),
),
)
.then((results) => {
let status: SpanStatus = { code: SPAN_STATUS_OK };
Expand Down
40 changes: 36 additions & 4 deletions server/hooks/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@ import {
union,
} from "valibot";

import { firewallAbi, firewallAddress } from "@exactly/common/generated/chain";
import { Address } from "@exactly/common/validation";

import database, { credentials } from "../database/index";
import allower from "../utils/allower";
import { createUser } from "../utils/panda";
import { addCapita, deriveAssociateId } from "../utils/pax";
import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona";
import {
addDocument,
headerValidator,
MANTECA_TEMPLATE_WITH_ID_CLASS,
PANDA_TEMPLATE,
pokeAccountAssets,
} from "../utils/persona";
import { customer } from "../utils/sardine";
import validatorHook from "../utils/validatorHook";

import type { InferOutput } from "valibot";

const Session = pipe(
object({
type: literal("inquiry-session"),
Expand Down Expand Up @@ -273,6 +282,20 @@ export default new Hono().post(
if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200);
}

if (firewallAddress) {
try {
const allowerClient = await allower();
await allowerClient.exaSend(
{ name: "exa.firewall", op: "exa.firewall", attributes: { account: credential.account, personaShareToken } },
{ address: firewallAddress, functionName: "allow", args: [credential.account, true], abi: firewallAbi },
{ ignore: [`AlreadyAllowed(${credential.account})`] },
);
} catch (error: unknown) {
captureException(error, { level: "error" });
return c.json({ code: "firewall error" }, 500);
}
}

// TODO implement error handling to return 200 if event should not be retried
const { id } = await createUser({
accountPurpose: fields.accountPurpose.value,
Expand All @@ -289,16 +312,16 @@ export default new Hono().post(
getActiveSpan()?.setAttributes({ "exa.pandaId": id });
setContext("persona", { inquiryId: personaShareToken, pandaId: id });

const account = safeParse(Address, credential.account);
if (account.success) {
const accountParsed = safeParse(Address, credential.account);
if (accountParsed.success) {
addCapita({
birthdate: fields.birthdate.value,
document: fields.identificationNumber.value,
firstName: fields.nameFirst.value,
lastName: fields.nameLast.value,
email: fields.emailAddress.value,
phone: fields.phoneNumber?.value ?? "",
internalId: deriveAssociateId(account.output),
internalId: deriveAssociateId(accountParsed.output),
product: "travel insurance",
}).catch((error: unknown) => {
captureException(error, { level: "error", extra: { pandaId: id, referenceId } });
Expand All @@ -309,6 +332,15 @@ export default new Hono().post(
level: "error",
});
}

if (accountParsed.success) {
pokeAccountAssets(accountParsed.output, {
notification: {
headings: { en: "Account assets updated" },
contents: { en: "Your funds are ready to use" },
},
}).catch(captureException);
}
addDocument(referenceId, {
id_class: { value: fields.identificationClass.value },
id_number: { value: fields.identificationNumber.value },
Expand Down
2 changes: 2 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "./utils/gcp";

import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { captureException, close as closeSentry } from "@sentry/node";
Expand Down
2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"@account-kit/infra": "catalog:",
"@exactly/lib": "^0.1.0",
"@google-cloud/kms": "^5.3.0",
"@hono/node-server": "^1.19.9",
"@hono/sentry": "^1.2.2",
"@hono/valibot-validator": "^0.5.3",
Expand All @@ -43,6 +44,7 @@
"@simplewebauthn/server": "^13.2.2",
"@types/debug": "^4.1.12",
"@valibot/to-json-schema": "^1.5.0",
"@valora/viem-account-hsm-gcp": "^1.2.16",
"async-mutex": "^0.5.0",
"bullmq": "^5.66.5",
"debug": "^4.4.3",
Expand Down
Loading
Loading