-
Notifications
You must be signed in to change notification settings - Fork 5
✨ server: automate firewall allow after kyc approval #1225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/infra": minor | ||
| --- | ||
|
|
||
| 🧱 setup credit queue worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ automate firewall allow after kyc approval |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/infra": minor | ||
| --- | ||
|
|
||
| 🧱 setup poke queue worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/infra": minor | ||
| --- | ||
|
|
||
| 🧱 setup allow queue worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ process poke in worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ process auto-credit in worker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| encryptionsalt: v1::v1:AAAAAAAAAAAAAAAb:rYjVO7/uF2+Qv1LhWq5c2S0ZR4rZaQ== | ||
| config: | ||
| gcp:project: eexxxaa | ||
| exa:pokeMinimum: 1 | ||
| exa:subscribeTimeout: 900s |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,11 +8,14 @@ export default define({ | |
| }, | ||
| }, | ||
| workers: { | ||
| allow: { signers: ["allower"] }, | ||
| chat: { | ||
| env: { WHATSAPP_PHONE_NUMBER_ID: "whatsappPhoneNumberId" }, | ||
| secrets: ["anthropic-api-key", "whatsapp-access-token"], | ||
| }, | ||
| credit: { secrets: ["onesignal-api-key", "postgres-url"] }, | ||
| hook: { secrets: ["panda-api-key", "postgres-url"], shared: ["panda-api-url"] }, | ||
| poke: { secrets: ["onesignal-api-key", "segment-write-key"], signers: ["poker"] }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In an infra-deployed worker this creates and loads a distinct Useful? React with 👍 / 👎.
Comment on lines
+11
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a fresh infra rollout, these entries cause Useful? React with 👍 / 👎. |
||
| refund: { | ||
| secrets: ["panda-api-key", "onesignal-api-key", "postgres-url", "sardine-api-key", "segment-write-key"], | ||
| shared: ["panda-api-url", "sardine-api-url"], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,10 +40,10 @@ import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exa | |
| import { Address, Base64URL, Hex } from "@exactly/common/validation"; | ||
|
|
||
| import { cards, credentials } from "../database/schema"; | ||
| import { autoCredit } from "../utils/panda"; | ||
| import publicClient from "../utils/publicClient"; | ||
| import ServiceError from "../utils/ServiceError"; | ||
| import validatorHook from "../utils/validatorHook"; | ||
| import { name as creditName } from "../workers/credit/job"; | ||
|
|
||
| import type * as schema from "../database/schema"; | ||
| import type { Auth } from "../middleware/auth"; | ||
|
|
@@ -53,6 +53,7 @@ import type createPersona from "../utils/persona"; | |
| import type createSardine from "../utils/sardine"; | ||
| import type createSegment from "../utils/segment"; | ||
| import type createWalletExtension from "../utils/walletExtension"; | ||
| import type createCredit from "../workers/credit/queue"; | ||
| import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | ||
|
|
||
| const CardResponse = object({ | ||
|
|
@@ -150,6 +151,7 @@ const Scopes = picklist(["provisioning", "siwe", "webauthn"]); | |
|
|
||
| export default function route({ | ||
| auth, | ||
| credit, | ||
| database, | ||
| panda, | ||
| pax, | ||
|
|
@@ -159,6 +161,7 @@ export default function route({ | |
| walletExtension, | ||
| }: { | ||
| auth: Auth; | ||
| credit: ReturnType<typeof createCredit>; | ||
| database: NodePgDatabase<typeof schema>; | ||
| panda: ReturnType<typeof createPanda>; | ||
| pax: ReturnType<typeof createPax>; | ||
|
|
@@ -644,15 +647,14 @@ This endpoint only accepts Wallet Extension bearer access. It does not accept \` | |
| } | ||
| }); | ||
|
|
||
| let mode = 0; | ||
| try { | ||
| if (await autoCredit(account)) mode = 1; | ||
| } catch (error) { | ||
| captureException(error); | ||
| } | ||
| await database | ||
| .insert(cards) | ||
| .values([{ id: card.id, credentialId, lastFour: card.last4, mode, productId }]); | ||
| await database.insert(cards).values([{ id: card.id, credentialId, lastFour: card.last4, productId }]); | ||
| await credit.enqueue(account).catch((error: unknown) => | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an already-funded account creates its card while Redis or BullMQ is temporarily unavailable, this catch returns success after storing the card in debit mode but discards the only credit evaluation scheduled by card creation. The earlier deposit's activity job may have completed before the card existed, and no later event is guaranteed to enqueue another evaluation, so an otherwise eligible card can remain in debit mode indefinitely; persist or retry this work without failing card issuance. Useful? React with 👍 / 👎. |
||
| captureException(error, { | ||
| level: "error", | ||
| tags: { queue: creditName, job: creditName }, | ||
| extra: { account }, | ||
| }), | ||
| ); | ||
|
Comment on lines
+650
to
+657
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Make credit publication durable. If Persist an outbox record in the same transaction as the card insert. Dispatch and retry that record until the credit queue accepts it. Update the queue-failure test to verify eventual dispatch. |
||
| segment.track({ | ||
| event: "CardIssued", | ||
| userId: account, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,52 +1,34 @@ | ||
| import { vValidator } from "@hono/valibot-validator"; | ||
| import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; | ||
| import { | ||
| captureException, | ||
| continueTrace, | ||
| getActiveSpan, | ||
| getTraceData, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
| setContext, | ||
| setTag, | ||
| setUser, | ||
| startSpan, | ||
| withScope, | ||
| } from "@sentry/node"; | ||
| import createDebug from "debug"; | ||
| import { eq, inArray } from "drizzle-orm"; | ||
| import { inArray } from "drizzle-orm"; | ||
| import { Hono } from "hono"; | ||
| import { validator } from "hono/validator"; | ||
| import * as v from "valibot"; | ||
| import { bytesToBigInt, hexToBigInt, withRetry, type LocalAccount } from "viem"; | ||
| import { bytesToHex, hexToBigInt } from "viem"; | ||
| import { anvil } from "viem/chains"; | ||
|
|
||
| import exaChain, { | ||
| auditorAbi, | ||
| exaAccountFactoryAbi, | ||
| exaPluginAbi, | ||
| exaPreviewerAbi, | ||
| exaPreviewerAddress, | ||
| marketAbi, | ||
| upgradeableModularAccountAbi, | ||
| wethAddress, | ||
| } from "@exactly/common/generated/chain"; | ||
| import exaChain, { exaPreviewerAbi, exaPreviewerAddress, wethAddress } from "@exactly/common/generated/chain"; | ||
| import { Address, Hash, Hex } from "@exactly/common/validation"; | ||
|
|
||
| import { cards, credentials } from "../database/schema"; | ||
| import { credentials } from "../database/schema"; | ||
| import t, { f } from "../i18n"; | ||
| import { activityNetworks, activityUrl, NETWORKS } from "../utils/alchemy"; | ||
| import decodePublicKey from "../utils/decodePublicKey"; | ||
| import { autoCredit } from "../utils/panda"; | ||
| import publicClient from "../utils/publicClient"; | ||
| import revertFingerprint from "../utils/revertFingerprint"; | ||
| import validatorHook from "../utils/validatorHook"; | ||
| import verifySignature from "../utils/verifySignature"; | ||
| import createWallet from "../utils/wallet"; | ||
|
|
||
| import type * as schema from "../database/schema"; | ||
| import type createAlchemy from "../utils/alchemy"; | ||
| import type createOnesignal from "../utils/onesignal"; | ||
| import type createSegment from "../utils/segment"; | ||
| import type createPoke from "../workers/poke/queue"; | ||
| import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | ||
| import type { Redis } from "ioredis"; | ||
|
|
||
|
|
@@ -59,17 +41,15 @@ Object.assign(debug, { inspectOpts: { depth: undefined } }); | |
| export default function hook({ | ||
| alchemy, | ||
| database, | ||
| executor, | ||
| onesignal, | ||
| poke, | ||
| redis, | ||
| segment, | ||
| }: { | ||
| alchemy: ReturnType<typeof createAlchemy>; | ||
| database: NodePgDatabase<typeof schema>; | ||
| executor: LocalAccount; | ||
| onesignal: ReturnType<typeof createOnesignal>; | ||
| poke: ReturnType<typeof createPoke>; | ||
| redis: Redis; | ||
| segment: ReturnType<typeof createSegment>; | ||
| }) { | ||
| const networks = activityNetworks(); | ||
| let entries = new Map<string, { network: string; signingKey: string }>(); | ||
|
|
@@ -204,150 +184,19 @@ export default function hook({ | |
| pokes.set(account, { publicKey, factory, source, assets: new Set([asset]) }); | ||
| } | ||
| } | ||
| const { "sentry-trace": sentryTrace, baggage } = getTraceData(); | ||
| const wallet = createWallet(executor, chain); | ||
| Promise.allSettled( | ||
| [...pokes].map(([account, { publicKey, factory, source, assets }]) => | ||
| continueTrace({ sentryTrace, baggage }, () => | ||
| withScope((scope) => | ||
| startSpan( | ||
| { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true }, | ||
| async (span) => { | ||
| scope.setUser({ id: account }); | ||
| const isDeployed = !!(await wallet.getCode({ address: account })); | ||
| scope.setTag("exa.new", !isDeployed); | ||
| if (!isDeployed) { | ||
| try { | ||
| await wallet.exaSend( | ||
| { name: "create account", op: "exa.account", attributes: { account } }, | ||
| { | ||
| address: factory, | ||
| functionName: "createAccount", | ||
| args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]], | ||
| abi: exaAccountFactoryAbi, | ||
| }, | ||
| chain.id === exaChain.id ? undefined : { fees: "auto" }, | ||
| ); | ||
| segment.track({ event: "AccountFunded", userId: account, properties: { source } }); | ||
| } catch (error: unknown) { | ||
| span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" }); | ||
| throw error; | ||
| } | ||
| } | ||
| if (chain.id !== exaChain.id) { | ||
| span.setStatus({ code: SPAN_STATUS_OK }); | ||
| return; | ||
| } | ||
| if (assets.has(ETH)) assets.delete(WETH); | ||
| const results = await Promise.allSettled( | ||
| [...assets] | ||
| .filter((asset) => marketsByAsset.has(asset) || asset === ETH) | ||
| .map(async (asset) => | ||
| withRetry( | ||
| () => | ||
| wallet | ||
| .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 | ||
| }), | ||
| }, | ||
| { ignore: ["NoBalance()"] }, | ||
| ) | ||
| .then((receipt) => { | ||
| if (receipt) return receipt; | ||
| throw new Error("NoBalance()"); | ||
| }), | ||
| { | ||
| delay: 2000, | ||
| retryCount: 5, | ||
| shouldRetry: ({ error }) => { | ||
| if (error instanceof Error && error.message === "NoBalance()") return true; | ||
| withScope((captureScope) => { | ||
| captureScope.setUser({ id: account }); | ||
| captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); | ||
| }); | ||
| return true; | ||
| }, | ||
| }, | ||
| ), | ||
| ), | ||
| ); | ||
| for (const result of results) { | ||
| if (result.status === "fulfilled") continue; | ||
| if (result.reason instanceof Error && result.reason.message === "NoBalance()") { | ||
| withScope((captureScope) => { | ||
| captureScope.setUser({ id: account }); | ||
| captureScope.addEventProcessor((event) => { | ||
| if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance"; | ||
| return event; | ||
| }); | ||
| captureException(result.reason, { | ||
| level: "warning", | ||
| fingerprint: ["{{ default }}", "NoBalance"], | ||
| }); | ||
| }); | ||
| continue; | ||
| } | ||
| span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" }); | ||
| throw result.reason; | ||
| } | ||
| autoCredit(account) | ||
| .then(async (auto) => { | ||
| 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"]), | ||
| }, | ||
| }, | ||
| }); | ||
| const card = credential?.cards[0]; | ||
| if (!card) return; | ||
| 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); | ||
| onesignal | ||
| .sendPushNotification({ | ||
| userId: account, | ||
| headings: t("Card mode changed"), | ||
| contents: t("Credit mode activated"), | ||
| }) | ||
| .catch((error: unknown) => captureException(error)); | ||
| }) | ||
| .catch((error: unknown) => captureException(error)); | ||
| span.setStatus({ code: SPAN_STATUS_OK }); | ||
| }, | ||
| ), | ||
| ), | ||
| ).catch((error: unknown) => { | ||
| withScope((scope) => { | ||
| scope.setUser({ id: account }); | ||
| captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); | ||
| }); | ||
| throw error; | ||
| await Promise.all( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| [...pokes].map(([account, { assets, factory, publicKey, source }]) => | ||
| poke.enqueue({ | ||
| account, | ||
| assets: [...assets], | ||
| chainId: chain.id, | ||
| factory, | ||
| origin: "activity", | ||
| publicKey: bytesToHex(publicKey), | ||
| source, | ||
| }), | ||
| ), | ||
| ) | ||
| .then((results) => { | ||
| getActiveSpan()?.setStatus( | ||
| results.every((result) => result.status === "fulfilled") | ||
| ? { code: SPAN_STATUS_OK } | ||
| : { code: SPAN_STATUS_ERROR, message: "activity_failed" }, | ||
| ); | ||
| }) | ||
| .catch((error: unknown) => captureException(error)); | ||
| ); | ||
|
Comment on lines
+187
to
+199
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Awaiting the enqueue makes webhook retries resend notifications. A rejected |
||
| return c.json({}); | ||
| }, | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In an infra deployment this creates a new
allowerKMS account, andserver/workers/allow/worker.tsuses that EOA to callFirewall.allowdirectly, but a repo-wide search finds no on-chain role grant or other provisioning for its address. Consequently Persona approvals enqueue transactions from an unauthorized signer, so allow jobs exhaust their retries and approved accounts remain blocked; reuse the already-authorized account or add an explicit firewall grant during deployment.Useful? React with 👍 / 👎.