diff --git a/.changeset/bold-ram-dig.md b/.changeset/bold-ram-dig.md new file mode 100644 index 000000000..cca53e6bd --- /dev/null +++ b/.changeset/bold-ram-dig.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ flatten simulate-proposal api and batch contract reads diff --git a/.changeset/brave-foxes-beam.md b/.changeset/brave-foxes-beam.md new file mode 100644 index 000000000..0c98eee23 --- /dev/null +++ b/.changeset/brave-foxes-beam.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support factory selection in credential creation diff --git a/.changeset/bright-eagle-catch.md b/.changeset/bright-eagle-catch.md new file mode 100644 index 000000000..847c26b46 --- /dev/null +++ b/.changeset/bright-eagle-catch.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 retry trace on resource not found diff --git a/.changeset/bright-foxes-swim.md b/.changeset/bright-foxes-swim.md new file mode 100644 index 000000000..c1969c4a0 --- /dev/null +++ b/.changeset/bright-foxes-swim.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 restructure swap state screen layouts diff --git a/.changeset/bright-panda-decline.md b/.changeset/bright-panda-decline.md new file mode 100644 index 000000000..03402aa4f --- /dev/null +++ b/.changeset/bright-panda-decline.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward declined transaction webhook diff --git a/.changeset/cool-icons-grow.md b/.changeset/cool-icons-grow.md new file mode 100644 index 000000000..7ad205cb4 --- /dev/null +++ b/.changeset/cool-icons-grow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 forward chain id in account client diff --git a/.changeset/cool-snakes-reply.md b/.changeset/cool-snakes-reply.md new file mode 100644 index 000000000..3e43d51ee --- /dev/null +++ b/.changeset/cool-snakes-reply.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ setup better-auth database tables diff --git a/.changeset/cuddly-streets-like.md b/.changeset/cuddly-streets-like.md new file mode 100644 index 000000000..336464b4c --- /dev/null +++ b/.changeset/cuddly-streets-like.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +✨ improve nonce usage for encrypted kyc diff --git a/.changeset/curly-pumas-jam.md b/.changeset/curly-pumas-jam.md new file mode 100644 index 000000000..29fef2b4e --- /dev/null +++ b/.changeset/curly-pumas-jam.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix owner wallet detection in siwe auth diff --git a/.changeset/cute-buttons-roll.md b/.changeset/cute-buttons-roll.md new file mode 100644 index 000000000..67e50d281 --- /dev/null +++ b/.changeset/cute-buttons-roll.md @@ -0,0 +1,5 @@ +--- +"@exactly/docs": patch +--- + +📝 add configuration resources diff --git a/.changeset/cyan-flies-camp.md b/.changeset/cyan-flies-camp.md new file mode 100644 index 000000000..4f21c256a --- /dev/null +++ b/.changeset/cyan-flies-camp.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix swaps query invalidation diff --git a/.changeset/dry-peas-ring.md b/.changeset/dry-peas-ring.md new file mode 100644 index 000000000..64a76b183 --- /dev/null +++ b/.changeset/dry-peas-ring.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🐛 fix update card webhook schema diff --git a/.changeset/every-cameras-trade.md b/.changeset/every-cameras-trade.md new file mode 100644 index 000000000..ca7b42be2 --- /dev/null +++ b/.changeset/every-cameras-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ allow account on firewall diff --git a/.changeset/fast-trees-prove.md b/.changeset/fast-trees-prove.md new file mode 100644 index 000000000..f7a773b79 --- /dev/null +++ b/.changeset/fast-trees-prove.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔥 remove redundant type check diff --git a/.changeset/fifty-friends-bet.md b/.changeset/fifty-friends-bet.md new file mode 100644 index 000000000..b3d74ac32 --- /dev/null +++ b/.changeset/fifty-friends-bet.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook logging for text response diff --git a/.changeset/four-canyons-mix.md b/.changeset/four-canyons-mix.md new file mode 100644 index 000000000..314483578 --- /dev/null +++ b/.changeset/four-canyons-mix.md @@ -0,0 +1,5 @@ +--- +"@exactly/common": patch +--- + +🔧 setup all onesignal domains diff --git a/.changeset/four-numbers-worry.md b/.changeset/four-numbers-worry.md new file mode 100644 index 000000000..6d50428b8 --- /dev/null +++ b/.changeset/four-numbers-worry.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ implement kyc data submission diff --git a/.changeset/fresh-kings-buy.md b/.changeset/fresh-kings-buy.md new file mode 100644 index 000000000..c66ea4d2d --- /dev/null +++ b/.changeset/fresh-kings-buy.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🦺 standardize card status diff --git a/.changeset/gentle-brooms-say.md b/.changeset/gentle-brooms-say.md new file mode 100644 index 000000000..8ecacf81d --- /dev/null +++ b/.changeset/gentle-brooms-say.md @@ -0,0 +1,5 @@ +--- +"@exactly/docs": patch +--- + +📝 update siwe authentication example diff --git a/.changeset/gold-cow-eat.md b/.changeset/gold-cow-eat.md new file mode 100644 index 000000000..f2fc6e06c --- /dev/null +++ b/.changeset/gold-cow-eat.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix bridge simulation falling back to default account diff --git a/.changeset/gold-ties-tan.md b/.changeset/gold-ties-tan.md new file mode 100644 index 000000000..fa6728903 --- /dev/null +++ b/.changeset/gold-ties-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🩹 return correct card status diff --git a/.changeset/great-dryers-kick.md b/.changeset/great-dryers-kick.md new file mode 100644 index 000000000..3a09c3175 --- /dev/null +++ b/.changeset/great-dryers-kick.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 pass chain id to bridge calls diff --git a/.changeset/hungry-impalas-sing.md b/.changeset/hungry-impalas-sing.md new file mode 100644 index 000000000..67b6435a6 --- /dev/null +++ b/.changeset/hungry-impalas-sing.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔧 set card art for sandbox diff --git a/.changeset/jolly-teeth-flow.md b/.changeset/jolly-teeth-flow.md new file mode 100644 index 000000000..75c157051 --- /dev/null +++ b/.changeset/jolly-teeth-flow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 fix repay amount selector font style diff --git a/.changeset/long-moons-brake.md b/.changeset/long-moons-brake.md new file mode 100644 index 000000000..adba8b5e5 --- /dev/null +++ b/.changeset/long-moons-brake.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook retries diff --git a/.changeset/long-nails-doubt.md b/.changeset/long-nails-doubt.md new file mode 100644 index 000000000..43f46804d --- /dev/null +++ b/.changeset/long-nails-doubt.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +📝 add sandbox dedicated ips diff --git a/.changeset/loose-papers-take.md b/.changeset/loose-papers-take.md new file mode 100644 index 000000000..d54edf7de --- /dev/null +++ b/.changeset/loose-papers-take.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ migrate remaining flows to send calls diff --git a/.changeset/loud-shoes-visit.md b/.changeset/loud-shoes-visit.md new file mode 100644 index 000000000..2ab24c16d --- /dev/null +++ b/.changeset/loud-shoes-visit.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix user webhook routing diff --git a/.changeset/lucky-jokes-change.md b/.changeset/lucky-jokes-change.md new file mode 100644 index 000000000..ec7d16fad --- /dev/null +++ b/.changeset/lucky-jokes-change.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ use gcp kms for allower diff --git a/.changeset/olive-onions-tan.md b/.changeset/olive-onions-tan.md new file mode 100644 index 000000000..ff26342e1 --- /dev/null +++ b/.changeset/olive-onions-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add webhook api diff --git a/.changeset/open-beds-stand.md b/.changeset/open-beds-stand.md new file mode 100644 index 000000000..7e135f695 --- /dev/null +++ b/.changeset/open-beds-stand.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 add bottom padding to swaps screen on web diff --git a/.changeset/pretty-chicken-hang.md b/.changeset/pretty-chicken-hang.md new file mode 100644 index 000000000..455b0e41f --- /dev/null +++ b/.changeset/pretty-chicken-hang.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +➕ install better-auth diff --git a/.changeset/quick-ants-write.md b/.changeset/quick-ants-write.md new file mode 100644 index 000000000..48dc1dc7f --- /dev/null +++ b/.changeset/quick-ants-write.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add transaction receipt to webhook diff --git a/.changeset/rare-pears-sort.md b/.changeset/rare-pears-sort.md new file mode 100644 index 000000000..089ab14bc --- /dev/null +++ b/.changeset/rare-pears-sort.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ allow better auth users to change email diff --git a/.changeset/sharp-squids-push.md b/.changeset/sharp-squids-push.md new file mode 100644 index 000000000..02c1901c8 --- /dev/null +++ b/.changeset/sharp-squids-push.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ add role to organization database table diff --git a/.changeset/shy-foxes-trade.md b/.changeset/shy-foxes-trade.md new file mode 100644 index 000000000..9a0a6491e --- /dev/null +++ b/.changeset/shy-foxes-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward exchange rate to webhooks diff --git a/.changeset/silly-yaks-divide.md b/.changeset/silly-yaks-divide.md new file mode 100644 index 000000000..d3f7700c5 --- /dev/null +++ b/.changeset/silly-yaks-divide.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ poke account after kyc diff --git a/.changeset/soft-eggs-count.md b/.changeset/soft-eggs-count.md new file mode 100644 index 000000000..a6de30733 --- /dev/null +++ b/.changeset/soft-eggs-count.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +📝 add create card docs diff --git a/.changeset/soft-trains-dig.md b/.changeset/soft-trains-dig.md new file mode 100644 index 000000000..4b9267b10 --- /dev/null +++ b/.changeset/soft-trains-dig.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support multiple cards in statement diff --git a/.changeset/sour-items-enjoy.md b/.changeset/sour-items-enjoy.md new file mode 100644 index 000000000..679b75861 --- /dev/null +++ b/.changeset/sour-items-enjoy.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 avoid chain mismatch on bridge transfer diff --git a/.changeset/tidy-geese-batch.md b/.changeset/tidy-geese-batch.md new file mode 100644 index 000000000..c4bcd395d --- /dev/null +++ b/.changeset/tidy-geese-batch.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ replace proposal simulation with simulate blocks diff --git a/.changeset/tidy-worms-sin.md b/.changeset/tidy-worms-sin.md new file mode 100644 index 000000000..c9849c26c --- /dev/null +++ b/.changeset/tidy-worms-sin.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix patch response validation diff --git a/.changeset/upset-seas-sink.md b/.changeset/upset-seas-sink.md new file mode 100644 index 000000000..e46154ae8 --- /dev/null +++ b/.changeset/upset-seas-sink.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add kyc role to organizations diff --git a/.changeset/violet-plums-move.md b/.changeset/violet-plums-move.md new file mode 100644 index 000000000..678c94b8e --- /dev/null +++ b/.changeset/violet-plums-move.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward webhooks to source diff --git a/.changeset/warm-eagles-glow.md b/.changeset/warm-eagles-glow.md new file mode 100644 index 000000000..fc200064f --- /dev/null +++ b/.changeset/warm-eagles-glow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 align propose and execute block timestamps diff --git a/.changeset/wicked-lies-invent.md b/.changeset/wicked-lies-invent.md new file mode 100644 index 000000000..64b4f085d --- /dev/null +++ b/.changeset/wicked-lies-invent.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ return card id diff --git a/.changeset/wide-cats-hug.md b/.changeset/wide-cats-hug.md new file mode 100644 index 000000000..9c5b922d7 --- /dev/null +++ b/.changeset/wide-cats-hug.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ pass explicit chain id to read hooks diff --git a/.changeset/wild-points-lose.md b/.changeset/wild-points-lose.md new file mode 100644 index 000000000..e939c74e7 --- /dev/null +++ b/.changeset/wild-points-lose.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 improve weak pin error handling diff --git a/.do/app.yaml b/.do/app.yaml index 28204aac2..cce0de126 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -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 diff --git a/.github/workflows/server-base.yaml b/.github/workflows/server-base.yaml index 9bbadd091..08f81bf91 100644 --- a/.github/workflows/server-base.yaml +++ b/.github/workflows/server-base.yaml @@ -1,6 +1,6 @@ name: server/base on: - push: { branches: [sandbox] } + push: { branches: [base] } jobs: build: uses: ./.github/workflows/server-build.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d74d38078..572ecbe38 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -103,12 +103,13 @@ jobs: pnpm nx run-many -t build -p mobile e2e pnpm nx generate:app server pnpm nx e2e server > server.log 2>&1 & - for i in {1..180}; do + pnpm dlx serve server/app -l 8081 -s & + for i in {1..420}; do curl -f http://localhost:3000/api/auth/authentication > /dev/null 2>&1 && break - [ $i -eq 180 ] && cat server.log && exit 1 + [ $i -eq 420 ] && cat server.log && exit 1 sleep 1 done - CHROME_LOG_FILE=$PWD/chrome.log xvfb-run maestro test .maestro/flows/web.yaml -e "APP_ID=http://localhost:3000" --format junit --output .maestro/junit.xml + CHROME_LOG_FILE=$PWD/chrome.log xvfb-run maestro test .maestro/flows/web.yaml --format junit --output .maestro/junit.xml env: { APP_DOMAIN: localhost, EXPO_PUBLIC_ENV: e2e } - if: always() run: | diff --git a/common/onesignalAppId.web.ts b/common/onesignalAppId.web.ts new file mode 100644 index 000000000..f47a99a92 --- /dev/null +++ b/common/onesignalAppId.web.ts @@ -0,0 +1,10 @@ +import domain from "./domain"; + +export default (process.env.EXPO_PUBLIC_ONESIGNAL_APP_ID || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- ignore empty string + { + "web.exactly.app": "31d4be98-1fa3-4a8c-9657-dc21c991adc7", + "base.exactly.app": "9f896065-637d-455c-baff-4041268dafce", + "sandbox.exactly.app": "15bd3cf9-f71e-43f2-96ff-e76916a832a3", + "base-sepolia.exactly.app": "893d33c6-d1bd-46cb-9047-d4d524f384f0", + }[domain]) ?? + "2f79a35c-8b11-4725-84d8-fc096f3f216e"; diff --git a/common/pandaCertificate.ts b/common/pandaCertificate.ts index f9b2564d1..c767ec95f 100644 --- a/common/pandaCertificate.ts +++ b/common/pandaCertificate.ts @@ -1,20 +1,26 @@ import domain from "./domain"; -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- ignore empty string */ -export default process.env.EXPO_PUBLIC_PANDA_PUBLIC_KEY || - { - "web.exactly.app": `-----BEGIN PUBLIC KEY----- +const production = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeZ9uCoxi2XvOw1VmvVLo88TLk GE+OO1j3fa8HhYlJZZ7CCIAsaCorrU+ZpD5PUTnmME3DJk+JyY1BB3p8XI+C5uno QucrbxFbkM1lgR10ewz/LcuhleG0mrXL/bzUZbeJqI6v3c9bXvLPKlsordPanYBG FZkmBPxc8QEdRgH4awIDAQAB ------END PUBLIC KEY-----`, - "sandbox.exactly.app": `-----BEGIN PUBLIC KEY----- +-----END PUBLIC KEY-----`; + +const sandbox = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCAP192809jZyaw62g/eTzJ3P9H +RmT88sXUYjQ0K8Bx+rJ83f22+9isKx+lo5UuV8tvOlKwvdDS/pVbzpG7D7NO45c 0zkLOXwDHZkou8fuj8xhDO5Tq3GzcrabNLRLVz3dkx0znfzGOhnY4lkOMIdKxlQb LuVM/dGDC9UpulF+UwIDAQAB ------END PUBLIC KEY-----`, +-----END PUBLIC KEY-----`; + +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- ignore empty string */ +export default process.env.EXPO_PUBLIC_PANDA_PUBLIC_KEY || + { + "web.exactly.app": production, + "base.exactly.app": production, + "base-sepolia.exactly.app": sandbox, + "sandbox.exactly.app": sandbox, }[domain] || `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCu2YOeObkaYiQmc49t2Cnk8syA diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index 7b94d6a44..9d07a1c12 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -97,7 +97,9 @@ export default defineConfig([ }), }, { - ...((chainId === base.id || chainId === anvil.id) && { scripts: { exaAccountFactory: "ExaAccountFactory" } }), + ...((chainId === base.id || chainId === baseSepolia.id || chainId === anvil.id) && { + scripts: { exaAccountFactory: "ExaAccountFactory" }, + }), optional: { balancerVault: balancerVault?.address, flashLoanAdapter: flashLoanAdapter?.address, @@ -167,8 +169,12 @@ function chain(): Plugin { content: `import { anvil, type Chain } from "viem/chains" const chain = anvil as Chain chain.rpcUrls.alchemy = chain.rpcUrls.default +chain.contracts = { multicall3: { address: "${optimism.contracts.multicall3.address}" } } chain.blockExplorers = { default: { name: "Otterscan", url: "http://localhost:5100" } } -export default chain as Chain & { rpcUrls: { alchemy: { http: readonly [string] } } }`, +export default chain as Chain & { + contracts: { multicall3: { address: \`0x\${string}\` } } + rpcUrls: { alchemy: { http: readonly [string] } } +}`, }), }; } @@ -185,7 +191,10 @@ export default chain as Chain & { rpcUrls: { alchemy: { http: readonly [string] run: () => ({ content: `import { ${importName} } from "@account-kit/infra" import { type Chain } from "viem/chains" -export default ${importName} as Chain & { rpcUrls: { alchemy: { http: readonly [string] } } }`, +export default ${importName} as Chain & { + contracts: { multicall3: { address: \`0x\${string}\` } } + rpcUrls: { alchemy: { http: readonly [string] } } +}`, }), }; } diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index bb916365f..c9aea82d6 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,5 +1,5 @@ -ExaAccountFactoryTest:testFuzz_createAccount_EOAOwners(uint256,address[63]) (runs: 256, μ: 5853309, ~: 5347535) -ExaAccountFactoryTest:test_deploy_deploysToSameAddress() (gas: 13989314) +ExaAccountFactoryTest:testFuzz_createAccount_EOAOwners(uint256,address[63]) (runs: 256, μ: 5857216, ~: 5360703) +ExaAccountFactoryTest:test_deploy_deploysToSameAddress() (gas: 14972464) ExaPluginTest:testFork_claimAndVestEscrowedEXA_claimsAndVests() (gas: 38664724) ExaPluginTest:testFork_collectCollateral_collects() (gas: 32167249) ExaPluginTest:testFork_crossRepay_repays() (gas: 33988652) @@ -212,14 +212,14 @@ IssuerCheckerTest:test_setPrevIssuerWindow_emits_PrevIssuerWindowSet() (gas: 526 IssuerCheckerTest:test_setPrevIssuerWindow_reverts_whenNotAdmin() (gas: 45548) MockSwapperTest:test_swapExactAmountIn_swaps() (gas: 269807) MockSwapperTest:test_swapExactAmountOut_swaps() (gas: 269803) -RedeployerTest:test_deployEXA_deploysAtSameAddress_onBase() (gas: 56250957) -RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onEthereum() (gas: 273369114) -RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onPolygon() (gas: 368043624) -RedeployerTest:test_deployExaFactory_deploysViaCreate3AtSameAddress_onPolygon() (gas: 45014545) -RedeployerTest:test_prepare_reverts_whenAdminIsDeployer() (gas: 28800535) -RedeployerTest:test_recoversNativeETHOnPolygon() (gas: 45185670) -RedeployerTest:test_run_reverts_whenAttackerUpgradesProxy() (gas: 38475794) -RedeployerTest:test_run_reverts_whenTargetNonceTooLow() (gas: 29265310) +RedeployerTest:test_deployEXA_deploysAtSameAddress_onBase() (gas: 56313588) +RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onEthereum() (gas: 273433983) +RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onPolygon() (gas: 368108493) +RedeployerTest:test_deployExaFactory_deploysViaCreate3AtSameAddress_onPolygon() (gas: 45079414) +RedeployerTest:test_prepare_reverts_whenAdminIsDeployer() (gas: 28865404) +RedeployerTest:test_recoversNativeETHOnPolygon() (gas: 45250539) +RedeployerTest:test_run_reverts_whenAttackerUpgradesProxy() (gas: 38540852) +RedeployerTest:test_run_reverts_whenTargetNonceTooLow() (gas: 29330179) RefunderTest:test_refund_refunds() (gas: 263363) RefunderTest:test_refund_reverts_whenExpired() (gas: 88359) RefunderTest:test_refund_reverts_whenNotKeeper() (gas: 68861) diff --git a/contracts/script/ExaAccountFactory.s.sol b/contracts/script/ExaAccountFactory.s.sol index 360c9ed80..3aa0a6f99 100644 --- a/contracts/script/ExaAccountFactory.s.sol +++ b/contracts/script/ExaAccountFactory.s.sol @@ -42,7 +42,7 @@ contract DeployExaAccountFactory is BaseScript { } function getAddress() external returns (address) { - etchCreate3(); + etchCanonical(); vm.etch(address(0), vm.getDeployedCode("ExaPlugin.sol:ExaPlugin")); return CREATE3_FACTORY.getDeployed(acct("admin"), _salt(IPlugin(address(0)))); } diff --git a/contracts/test/Fork.t.sol b/contracts/test/Fork.t.sol index 237ec21aa..9f19be892 100644 --- a/contracts/test/Fork.t.sol +++ b/contracts/test/Fork.t.sol @@ -25,17 +25,26 @@ abstract contract ForkTest is Test { else if (block.chainid == 84_532) CREATE3_FACTORY = ICreate3Factory(0x9f275F6D25232FFf082082a53C62C6426c1cc94C); else CREATE3_FACTORY = ICreate3Factory(0x93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1); vm.label(address(CREATE3_FACTORY), "CREATE3Factory"); - if (block.chainid == getChain("anvil").chainId) etchCreate3(); + if (block.chainid == getChain("anvil").chainId) etchCanonical(); } - function etchCreate3() internal { - bytes memory code = + function etchCanonical() internal { + bytes memory create3Code = hex"6080604052600436106100295760003560e01c806350f1c4641461002e578063cdcb760a14610077575b600080fd5b34801561003a57600080fd5b5061004e610049366004610489565b61008a565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61004e6100853660046104fd565b6100ee565b6040517fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606084901b166020820152603481018290526000906054016040516020818303038152906040528051906020012091506100e78261014c565b9392505050565b6040517fffffffffffffffffffffffffffffffffffffffff0000000000000000000000003360601b166020820152603481018390526000906054016040516020818303038152906040528051906020012092506100e78383346102b2565b604080518082018252601081527f67363d3d37363d34f03d5260086018f30000000000000000000000000000000060209182015290517fff00000000000000000000000000000000000000000000000000000000000000918101919091527fffffffffffffffffffffffffffffffffffffffff0000000000000000000000003060601b166021820152603581018290527f21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f60558201526000908190610228906075015b6040516020818303038152906040528051906020012090565b6040517fd69400000000000000000000000000000000000000000000000000000000000060208201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606083901b1660228201527f010000000000000000000000000000000000000000000000000000000000000060368201529091506100e79060370161020f565b6000806040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000858251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff811661037d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f4445504c4f594d454e545f4641494c454400000000000000000000000000000060448201526064015b60405180910390fd5b6103868661014c565b925060008173ffffffffffffffffffffffffffffffffffffffff1685876040516103b091906105d6565b60006040518083038185875af1925050503d80600081146103ed576040519150601f19603f3d011682016040523d82523d6000602084013e6103f2565b606091505b50509050808015610419575073ffffffffffffffffffffffffffffffffffffffff84163b15155b61047f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f494e495449414c495a4154494f4e5f4641494c454400000000000000000000006044820152606401610374565b5050509392505050565b6000806040838503121561049c57600080fd5b823573ffffffffffffffffffffffffffffffffffffffff811681146104c057600080fd5b946020939093013593505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000806040838503121561051057600080fd5b82359150602083013567ffffffffffffffff8082111561052f57600080fd5b818501915085601f83011261054357600080fd5b813581811115610555576105556104ce565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561059b5761059b6104ce565b816040528281528860208487010111156105b457600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000825160005b818110156105f757602081860181015185830152016105dd565b50600092019182525091905056fea26469706673582212201ff95c2aafa102481fdd22c59ee7f98a92a9662a6566ab5e0498e8bb47a5f30c64736f6c63430008110033"; - vm.etch(address(CREATE3_FACTORY), code); + vm.etch(address(CREATE3_FACTORY), create3Code); + + bytes memory multicall3Code = + hex"6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fd"; + vm.etch(MULTICALL3_ADDRESS, multicall3Code); + try vm.activeFork() { vm.rpc( "anvil_setCode", - string.concat('["', address(CREATE3_FACTORY).toHexString(), '","', code.toHexString(), '"]') // solhint-disable-line quotes + string.concat('["', address(CREATE3_FACTORY).toHexString(), '","', create3Code.toHexString(), '"]') // solhint-disable-line quotes + ); + vm.rpc( + "anvil_setCode", + string.concat('["', MULTICALL3_ADDRESS.toHexString(), '","', multicall3Code.toHexString(), '"]') // solhint-disable-line quotes ); } catch { } // solhint-disable-line no-empty-blocks } diff --git a/cspell.json b/cspell.json index 697d00b63..74f602936 100644 --- a/cspell.json +++ b/cspell.json @@ -38,9 +38,11 @@ "checkpointing", "checksummed", "clippy", + "ciphertext", "codegen", "codepoint", "colocating", + "comlink", "cosealg", "cosekeys", "creds", @@ -76,6 +78,7 @@ "hdpi", "hexlify", "hideable", + "hmac", "hono", "IBMPlexMono-Medm", "IERC", @@ -105,6 +108,7 @@ "mload", "modelcontextprotocol", "moti", + "multicall", "mysten", "natspec", "nfmelendez", @@ -174,6 +178,7 @@ "valibot", "valierror", "valkey", + "valora", "viem", "viewability", "wagmi", diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 5c83c0ebe..ac99ea1f8 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,10 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: openAPISidebarGroups, + sidebar: [ + { label: "Docs", items: ["index", "organization-authentication", "resources", "webhooks"] }, + ...openAPISidebarGroups, + ], }), mermaid(), ], diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md new file mode 100644 index 000000000..ebb189e9d --- /dev/null +++ b/docs/src/content/docs/organization-authentication.md @@ -0,0 +1,530 @@ +--- +title: Organizations, authentication and authorization +sidebar: + label: Organizations and authentication + order: 10 +--- + +Creating organizations is permission-less. Any user can create an organization and will be the owner. +Then the owner can add members with admin role and those admins will be able to add more members with different roles. + +Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. + +> ⚠️ **Note:** +> If you need to perform an encrypted KYC operation, please ask the exa team for `kyc` permissions. + +## SIWE Authentication + +Example code to authenticate using SIWE, it will create the user if doesn't exist with an auto generated email that will be needed +when an admin generates invites. It is possible also to change the auto generated email to a custom one using `authClient.changeEmail` + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { privateKeyToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const domain = "sandbox.exactly.app"; + +const authClient = createAuthClient({ + baseURL: `https://${domain}`, + plugins: [siweClient(), organizationClient()], +}); +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + //can be any statement + const statement = "i accept exa terms and conditions"; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://${domain}`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + console.log("j", JSON.stringify(context.data, null, 2)); + const headers = new Headers(); + const cookie = context.response.headers.get("set-cookie"); + if (!cookie) throw new Error("No cookie"); + headers.set("cookie", cookie); + console.log("default email for invites", `${owner.address.toLowerCase()}@https://${domain}`); + console.log("auth cookie", cookie); + const changeEmail = false; + if (changeEmail) { + const { data: changeEmailResult, error: changeEmailError } = await authClient.changeEmail({ + fetchOptions: { + headers, + }, + newEmail: "foo@example.com", + }); + if (changeEmailResult?.status) { + console.log("new email for invites: foo@example.com", changeEmailResult); + } else { + console.error("error changing email", changeEmailError); + } + } + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); +``` + +*Output changeEmail=false:* + +```log +default email for invites 0xd2e4862f5b12888750c3de8bd355a8bea72563db@https://sandbox.exactly.app +auth cookie __Secure-better-auth.session_token=************************.hdFMxm%2B3lfFT1r0PzlAJV1rBu1158FIMNWRCsPyKc20%3D; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax, __cf_bm=xnlWakZTNl.7UbT9hFNiwBoVaynqh_JAAIdKpKD0VxM-1759413526-1.0.1.1-cFxObTiGDHlFoAfPHuU0ha4W_ha9_zwmFWTKcrTC0Zr6MCmtUVGpMLMxH5GX2HiekLpnXFNMJ415sVPuJRO8H2EfywCSEqbulhMxzbYMezw; path=/; expires=Thu, 02-Oct-25 14:28:46 GMT; domain=.sandbox.exactly.app; HttpOnly; Secure; SameSite=None +``` + +*Output changeEmail=true:* + +```log +default email for invites 0xd2e4862f5b12888750c3de8bd355a8bea72563db@https://sandbox.exactly.app +auth cookie __Secure-better-auth.session_token=******************.dHecjPNjsnJ5CyRtsZ%2FovQbtMsDJgpeSWVD2OlycBW4%3D; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax, __cf_bm=dplXTM4T0iJfoIzqnFGZagTOYedVS6a9tIZGoZeomYU-1759413785-1.0.1.1-0fZC9AG_Y9FDvGSmOJKq5r81Vrvw8c_GwHf6Afh_gNMibNFWLbeX6_YFv2F7VDj9FiuavPdCL.yS7h0MSF92asErgnhDUZu4262YzTacY3s; path=/; expires=Thu, 02-Oct-25 14:33:05 GMT; domain=.sandbox.exactly.app; HttpOnly; Secure; SameSite=None +new email for invites: foo@example.com { status: true } +``` + +## Creating an organization + +owner account will be the owner of the created organization + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import type { Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = baseSepolia.id; +const API_BASE_URL = process.env.API_BASE_URL; +if (!API_BASE_URL) throw new Error("API_BASE_URL environment variable is required"); + +const authClient = createAuthClient({ + baseURL: process.env.API_BASE_URL, + plugins: [siweClient(), organizationClient()], +}); + +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as Hex); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: API_BASE_URL, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: new URL(API_BASE_URL).hostname, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const createOrganizationResult = await authClient.organization.create({ + fetchOptions: { headers }, + name: "Uphold", + slug: "uphold", + keepCurrentActiveOrganization: false, + }); + if (createOrganizationResult.data) { + console.log(`organization created id: ${createOrganizationResult.data.id}`); + } else { + console.error("Failed to create organization error:", createOrganizationResult.error); + } + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }).catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` + +## Using properly the header to create a webhook + + ```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; +const baseURL = "http://localhost:3000"; +const authClient = createAuthClient({ + baseURL, + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const webhooks = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + }); + console.log("webhooks", webhooks); + + // only if owner or admin roles for the organization + const newWebhook = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + method: "POST", + body: { + name: "foobar", + url: "https://test.com", + }, + }); + console.log("new webhook", newWebhook); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + + ``` + +## How to create the encrypted KYC payload with SIWE statement + + +```typescript +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { + key: key.toString("base64"), + iv: iv.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64"), + hash: sha256(ciphertext), + }; +} + +const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, +}; +const encryptedPayload = encrypt(JSON.stringify(data)); +const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; +const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; +const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", +}); +owner.signMessage({ message }) + .then((signature) => { + const verify = { + message, + signature, + walletAddress: owner.address, + chainId, + }; + const { hash, ...payload } = encryptedPayload; + console.log("application payload", { ...payload, verify }); + }) + .catch((error: unknown) => { + console.error("error", error); + }); + ``` + +## How to send an Invite to the Integrator organization + +The integrator address needs to have owner or admin roles. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { privateKeyToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const domain = "sandbox.exactly.app"; + +const authClient = createAuthClient({ + baseURL: `https://${domain}`, + plugins: [siweClient(), organizationClient()], +}); + +// send invite + +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const statement = `i accept exa terms and conditions`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://${domain}`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const { data, error } = await authClient.organization.inviteMember({ + email: "bob@integrator.com", + role: "admin", + organizationId: "", + fetchOptions: { headers }, + }); + if (!data) { + console.error(error); + return; + } + console.log(`invite id ${data.id}, email ${data.email}. Expires at ${data.expiresAt.toISOString()}`); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` + +## How to accept an invite from the integrator organization + +Use the invite id generated by the owner or the admin role of the organization and your private key. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { privateKeyToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const domain = "sandbox.exactly.app"; + +const authClient = createAuthClient({ + baseURL: `https://${domain}`, + plugins: [siweClient(), organizationClient()], +}); + +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const statement = `i accept exa terms and conditions`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://${domain}`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const { data, error } = await authClient.organization.acceptInvitation({ + fetchOptions: { + headers, + }, + invitationId: "", + }); + if (!data) { + console.error(error); + return; + } + console.log(data); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("error", error); + }); + ``` diff --git a/docs/src/content/docs/resources.md b/docs/src/content/docs/resources.md new file mode 100644 index 000000000..d5222f505 --- /dev/null +++ b/docs/src/content/docs/resources.md @@ -0,0 +1,76 @@ +--- +title: Resources +sidebar: + label: Resources + order: 10 +--- + +Key resources to setup an integration + +## Exa server IPs + +IP allow listing prevents spoofing, reduces attack surface, and adds network-level security to webhooks. + +### Sandbox - Exa backend OP sepolia + +- 209.38.69.78 +- 143.198.79.59 + +### Sandbox - Exa backend Base sepolia + +- 164.92.75.196 +- 146.190.173.21 + +## Rain RSA public key for card details and pin + +### Sandbox + +``` bash +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCAP192809jZyaw62g/eTzJ3P9H ++RmT88sXUYjQ0K8Bx+rJ83f22+9isKx+lo5UuV8tvOlKwvdDS/pVbzpG7D7NO45c +0zkLOXwDHZkou8fuj8xhDO5Tq3GzcrabNLRLVz3dkx0znfzGOhnY4lkOMIdKxlQb +LuVM/dGDC9UpulF+UwIDAQAB +-----END PUBLIC KEY----- +``` + +### Production + +``` bash +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeZ9uCoxi2XvOw1VmvVLo88TLk +GE+OO1j3fa8HhYlJZZ7CCIAsaCorrU+ZpD5PUTnmME3DJk+JyY1BB3p8XI+C5uno +QucrbxFbkM1lgR10ewz/LcuhleG0mrXL/bzUZbeJqI6v3c9bXvLPKlsordPanYBG +FZkmBPxc8QEdRgH4awIDAQAB +-----END PUBLIC KEY----- +``` + +## Rain RSA public key for KYC + +### Sandbox + +``` bash +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +``` + +### Production + +``` bash +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2lAlKSFsuOXjP0ULiZcl +Q7y2D5HW7zU/tU9fQLamzWpFw6enJ4eZAaTnk9RRtTG178NR8vl/2WM2qq+WJk+Q +pFyxIaQ0eVvOI/aP3DluF0kHK9u2pSV66Pl06zayIZJu3LAyCkHHoj4pWw7q6rTl +Va8CeJaICTS2g1J6ntVjpCIfUcJAor2OL4W/cimPOJwMdK/sJ5a2v9k85nEX17Xi +IDw8tK44ycj8s/odg0GoZG7B6IsLb2lKFaBLZoWKUqK8vYodcaXj/CMjLteuay7r +lHiLEduzxfqeFvR3s3jJTS6sdUAcLZLk/xXuoNg1pOF5M4JpxMX/TehxlIzaBovd +EQIDAQAB +-----END PUBLIC KEY----- +``` diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md new file mode 100644 index 000000000..f4d5328d0 --- /dev/null +++ b/docs/src/content/docs/webhooks.md @@ -0,0 +1,656 @@ +--- +title: Webhooks +sidebar: + label: Webhooks + order: 10 +--- + +Webhooks enable real-time event notifications, allowing you to integrate external systems with Exa. + +## Setting up webhooks + +A default endpoint can be configured and optionally an endpoint for each of the 5 event types: + +- Transaction created +- Transaction updated +- Transaction completed +- User updated +- Card updated + +## Webhook security and signing + +Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the Signature HTTP header of the request. + +You can verify webhook authenticity by computing the HMAC signature and comparing it to the `Signature` header included in the webhook request. + +Example: Verifying a webhook signature (Node.js) + +```typescript +import { createHmac } from "crypto"; + +const signature = createHmac("sha256", ) + .update() // JSON.stringify(payload) + .digest("hex"); +``` + +Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. + +## Retry policy and timeout + +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 2xx or times out. + +| Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | +| --- | --- | --- | --- | +| 0 | 500 | 0.5s | - | +| 1 | 1,000 | 1s | - | +| 2 | 2,000 | 2s | - | +| 3 | 4,000 | 4s | - | +| 4 | 8,000 | 8s | - | +| 5 | 16,000 | 16s | - | +| ..... | | | | +| 16 | 32,768,000 | 32768s | ~546.1min | +| 17 | 65,536,000 | 65536s | ~1092.3min | +| 18 | 131,072,000 | 131072s | ~2184.5min | +| 19 | 262,144,000 | 262144s | ~4369.1min | + +## Webhook flows + +There are 5 different types of flow that uses different events which details are in the `Event reference` section: + +- Purchase lifecycle with settlement +- Partial capture +- Over capture +- Force capture +- Refund + +### Purchase lifecycle with settlement + +This example demonstrates a complete transaction lifecycle through webhook notifications, showing how a transaction progresses from initial transaction created to final settlement with an amount adjustment. + +```mermaid +sequenceDiagram + participant Merchant + participant Exa + participant Blockchain + participant Uphold + + + Merchant->>Exa: auth request ($100) + activate Exa + + Exa->>Blockchain: simulate collect ($100) + activate Blockchain + Note over Blockchain: total collect simulation ($100) + Blockchain-->>Exa: simulation OK + deactivate Blockchain + Exa-->>Merchant: auth approved + + Exa->>Blockchain: collect ($100) + activate Blockchain + Note over Blockchain: total collect ($100) + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + + deactivate Exa + + Exa->>Uphold: transaction.created webhook ($100) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + Note over Merchant,Uphold: Time passes (usually same day) + + + Merchant->>Exa: reversal request (-20) + activate Exa + + Exa->>Blockchain: Refund ($20) + activate Blockchain + Note over Blockchain: Refund + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + Exa-->>Merchant: reversal approved + + Exa->>Uphold: webhook transaction.updated (-20) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + deactivate Exa + Note over Merchant,Uphold: Time passes (usually 3 business days) + + Exa->>Uphold: webhook transaction.completed (80) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + +``` + +#### Transaction Created + +Transaction authorized and created with timestamp, for $100.00 amount. + +```json +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T14:36:04.586Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Updated + +Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 +Note that this is a reversal, 1 of the 3 types of refunds. + +```json +{ + "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + "timestamp": "2025-08-12T20:08:37.707Z", + "resource": "transaction", + "action": "updated", + "receipt": { + "blockNumber": 98, + "transactionHash": "0x8c6ef90db7901c43018b3b079ac5ccf84e9c1eb2aaf0fd5f1f8b3e2b97d25fa3", + }, + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "authorizationUpdateAmount": -2000, + "status": "reversed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $80.00 with status "completed". + +```json +{ + "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", + "timestamp": "2025-08-12T20:23:20.662Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "status": "completed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +### Partial capture flow + +In a partial capture, the merchant settles for less than the authorized amount. After receiving the transaction completed webhook, the over authorized and captured funds are released to the user. This flow is common in restaurants, where the final charge may be lower than the original authorization after accounting for tips. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```json +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T16:37:08.862Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 108, + "transactionHash": "0x59be2972d1094e6abc14f595b71ed4e9e6ec4e2cd8d61e292f6debcba37e19b4", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. + +```json +{ + "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", + "timestamp": "2025-08-13T16:42:28.955Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 109, + "transactionHash": "0xd3b27341a97f4621865d896713a82be4099c5e0ad18782fb134fa33a77bba937", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 9000, // notice the partial capture + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 9000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5511", + "merchantName": "PartialCapture Example", + "authorizedAt": "2025-07-03T18:40:28.024Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Partial capture Example", + "enrichedMerchantCategory": "Business - Software" + } + } +} +``` + +### Over Capture + +In an over capture, the merchant settles for more than the originally authorized amount. This flow is typically used in scenarios that involve tips or additional surcharges, such as dining or hospitality. +Certain industries, like restaurants and bars, are allowed to settle for more than the authorized amount—typically up to 20%—to accommodate tips and similar charges. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```json +{ + "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", + "timestamp": "2025-08-13T16:53:21.455Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 300, + "transactionHash": "0x7faf9d14fde333a946c27f9e173c2d640ef3b4fbafc7e75d2a8a4b8743efb001", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5812 - Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. + +```json +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T16:55:11.934Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 499, + "transactionHash": "0x2d3a8b61a94f5f36b0d64f3e6a7c5e1bb7eeba6004cd3f1dc7c02b265aec7b02", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 11000, // notice the increase in the amount of settlement + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Force Capture + +A force capture occurs when a merchant settles a transaction without prior authorization. These transactions bypass the authorization phase and proceed directly to settlement. This flow is typically used in offline scenarios, such as in-flight purchases where the merchant does not have internet access. + +#### Transaction completed + +```json +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T17:00:08.061Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "type": "spend", + "spend": { + "amount": 11000, + "currency": "usd", + "cardId": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Refund + +Refunds are treated as negative transactions and may or may not reference the original transaction completed. Unlike reversals, refunds can be initiated independently of the original transaction and may occur well after the initial settlement. + +#### Transaction created + +The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and +do sanity checks. + +```json +{ + "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + "timestamp": "2025-08-13T17:08:50.609Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5641 - Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "pending" + } + } +} + ``` + +#### Transaction Completed + +Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. + +```json +{ + "id": "77474a56-51eb-4918-b09e-73cf20077b1b", + "timestamp": "2025-08-13T17:12:48.858Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "completed", + "enrichedMerchantName": "Test Refund", + "enrichedMerchantCategory": "Refunds - Insufficient Funds" + } + } +} +``` + +## Refunds + +There are 3 types of operations that return funds to the user: reversal, partial capture, and refund. + +### Reversal + +This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day. + +#### Partial capture + +This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card. + +#### Refund + +Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week. + +| Operation | Display | Time | +| --- | --- | --- | +| reversal | purchase details | same day | +| partial | purchase details | 2 or 3 business day | +| refunds | activity | weeks | + +## Event reference + +### Transaction created event + +The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. +This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. +The onchain receipt will be present only if a onchain transaction is necessary. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhookId and always the same when retry | 372d1a76-8a57-403e-a7f3-ac3231be144c | +| timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | +| resource | "transaction" | | transaction | +| action | "created" | | created | +| receipt?.blockNumber | number | onchain transaction block number | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | +| body.type | "spend" | | spend | +| body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | +| body.spend.currency | string | Always in usd ISO 4217 | usd | +| body.spend.cardId | string | | 47c3c3b3-b197-4a97-ace3-901a6ad7cf61 | +| body.spend.localAmount | integer | Purchase amount in local currency | 100 | +| body.spend.localCurrency | string | The local currency ISO 4217 | eur | +| body.spend.merchantCity? | string | The merchant city | "San Francisco" | +| body.spend.merchantCountry? | string | The merchant country | "US" | +| body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | +| body.spend.authorizedAmount | integer | The authorized amount | 100 | +| body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | +| body.spend.declinedReason? | string | Decline message | webhook declined | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | + +### Transaction updated event + +This webhook is sent whenever a transaction is updated. Note that the transaction may not have been created before this update. +Triggered for events such as incremental authorizations or reversals (a type of refund). + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | e972a2b0-a990-47af-b460-500ff75fbf65 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | +| resource | "transaction" | | transaction | +| action | "updated" | | updated | +| receipt.blockNumber | number | onchain transaction block number | 97 | +| receipt.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | amount in usd authorized | 2499 | +| body.spend.currency | string | always dollars ISO 4217 | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | amount in local currency authorized | 2499 | +| body.spend.localCurrency | string | currency of the purchase ISO 4217 | usd | +| body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | +| body.spend.merchantCountry? | string | country of the merchant | US | +| body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.merchantName | string | name of the merchant | UBER *TRIP | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | +| body.spend.authorizedAmount | number | amount authorized | 2499 | +| body.spend.authorizationUpdateAmount | number | amount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization | 726 | +| body.spend.status | "pending" \| "reversed" \| "declined" | current status of the transaction | pending | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Uber | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Transport - Rides | + +### Transaction completed event + +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. The `receipt` exist only +if an onchain transaction is necessary. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 662eb701-f9ac-4baa-9f86-b341a730c6dc | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | +| resource | "transaction" | | transaction | +| action | "completed" | | completed | +| receipt?.blockNumber | number | onchain transaction block number. | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | final settled amount in usd | 1041 | +| body.spend.currency | string | always dollars ISO 4217 | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | final settled amount in local currency | 1270000 | +| body.spend.localCurrency | string | currency of the purchase ISO 4217 | ars | +| body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | +| body.spend.merchantCountry? | string | country of the merchant | AR | +| body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | +| body.spend.authorizedAmount | number | original authorized amount | 1035 | +| body.spend.status | "completed" | final status of the transaction | completed | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | + +### User updated + +This webhook is sent whenever a user's compliance status is updated. No response is required. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | bdc87700-bf6d-4d7d-ac29-3effb06e3000 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T19:16:56.709Z | +| resource | "user" | | user | +| action | "updated" | | updated | +| body.credentialId | string | credential id | 0xE18847D2f02cE2800C07c5b42e66c819eC78d35f | +| body.applicationReason | string | reason for application status | COMPROMISED_PERSONS, PEP | +| body.applicationStatus | "approved" \| "pending" \| "needsInformation" \| "needsVerification" \| "manualReview" \| "denied" \| "locked" \| "canceled" | current status of the application | pending | +| body.isActive | boolean | whether the user is active | true | + +### Card updated + +This webhook is currently triggered when a user adds their card to a digital wallet. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 31740000-bd68-40c8-a400-5a0131f58800 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:47:33.687Z | +| resource | "card" | | card | +| action | "updated" | | updated | +| body.id | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.last4 | string | last 4 digits of the card | 7392 | +| body.limit.amount | number | spending limit amount | 1000000 | +| body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | +| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" | current status of the card | ACTIVE | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | diff --git a/package.json b/package.json index 6adc10184..27da0324f 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "abitype": "^1.2.3", "buffer": "^6.0.3", "burnt": "^0.13.0", + "comlink": "^4.4.2", "date-fns": "^4.1.0", "expo": "^54.0.31", "expo-application": "~7.0.8", @@ -168,6 +169,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@wagmi/core": "catalog:", "abitype>zod": "^4.0.0", + "comlink": "$comlink", "axios@1.13.2": "^1.13.5", "bn.js": "^5.2.3", "esbuild@0.18.20": "^0.25.0", @@ -202,6 +204,7 @@ "solidity-coverage": "npm:@favware/skip-dependency@1.2.2" }, "patchedDependencies": { + "@farcaster/miniapp-sdk": "patches/@farcaster__miniapp-sdk.patch", "@lifi/sdk": "patches/@lifi__sdk.patch", "embedded-postgres": "patches/embedded-postgres.patch", "eslint-config-universe": "patches/eslint-config-universe.patch", diff --git a/patches/@farcaster__miniapp-sdk.patch b/patches/@farcaster__miniapp-sdk.patch new file mode 100644 index 000000000..a6c1bac3b --- /dev/null +++ b/patches/@farcaster__miniapp-sdk.patch @@ -0,0 +1,68 @@ +diff --git a/dist/ethereumProvider.js b/dist/ethereumProvider.js +index a610b1ca8df86c4e156cdb3a925bcf25fee0bd3b..781b021d6b99a7f8d8b04d8d14d9b8dd22f02c5d 100644 +--- a/dist/ethereumProvider.js ++++ b/dist/ethereumProvider.js +@@ -4,21 +4,32 @@ import * as RpcResponse from 'ox/RpcResponse'; + import { miniAppHost } from "./miniAppHost.js"; + const emitter = Provider.createEmitter(); + const store = RpcRequest.createStore(); +-function toProviderRpcError({ code, details, }) { ++const replacer = (_, v) => typeof v === 'bigint' ? `0x${v.toString(16)}` : v; ++function toProviderRpcError(error, context) { ++ const { code, details } = error; ++ let e; + switch (code) { + case 4001: +- return new Provider.UserRejectedRequestError(); ++ e = new Provider.UserRejectedRequestError({ message: details }); ++ break; + case 4100: +- return new Provider.UnauthorizedError(); ++ e = new Provider.UnauthorizedError(); ++ break; + case 4200: +- return new Provider.UnsupportedMethodError(); ++ e = new Provider.UnsupportedMethodError(); ++ break; + case 4900: +- return new Provider.DisconnectedError(); ++ e = new Provider.DisconnectedError(); ++ break; + case 4901: +- return new Provider.ChainDisconnectedError(); ++ e = new Provider.ChainDisconnectedError(); ++ break; + default: +- return new Provider.ProviderRpcError(code, details ?? 'Unknown provider RPC error'); ++ e = new Provider.ProviderRpcError(code, details ?? 'Unknown provider RPC error'); ++ break; + } ++ try { e.cause = new Error(JSON.stringify(context, replacer)); } catch {} ++ return e; + } + export const ethereumProvider = Provider.from({ + ...emitter, +@@ -26,11 +37,11 @@ export const ethereumProvider = Provider.from({ + // @ts-expect-error + const request = store.prepare(args); + try { +- const response = await miniAppHost +- .ethProviderRequestV2(request) +- .then((res) => RpcResponse.parse(res, { request, raw: true })); ++ const raw = await miniAppHost.ethProviderRequestV2(request); ++ const response = RpcResponse.parse(raw, { request, raw: true }); + if (response.error) { +- throw toProviderRpcError(response.error); ++ globalThis.__captureProviderError__?.(args.method, { request: args, raw }); ++ throw toProviderRpcError(response.error, { request: args, raw }); + } + return response.result; + } +@@ -44,6 +55,7 @@ export const ethereumProvider = Provider.from({ + e instanceof RpcResponse.BaseError) { + throw e; + } ++ globalThis.__captureProviderError__?.(args.method, { request: args, error: e instanceof Error ? { message: e.message, name: e.name, stack: e.stack } : e }); + throw new RpcResponse.InternalError({ + message: e instanceof Error ? e.message : undefined, + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75070def3..f6229620e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,10 +53,11 @@ catalogs: version: 3.3.4 overrides: - abitype>zod: ^4.0.0 '@isaacs/brace-expansion': ^5.0.1 '@modelcontextprotocol/sdk': ^1.26.0 '@wagmi/core': ^3.2.2 + abitype>zod: ^4.0.0 + comlink: ^4.4.2 axios@1.13.2: ^1.13.5 bn.js: ^5.2.3 esbuild@0.18.20: ^0.25.0 @@ -91,6 +92,9 @@ overrides: solidity-coverage: npm:@favware/skip-dependency@1.2.2 patchedDependencies: + '@farcaster/miniapp-sdk': + hash: c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e + path: patches/@farcaster__miniapp-sdk.patch '@lifi/sdk': hash: ee16233f297d9a6c8a8320b5dc2b4bf47b7be8b481d79e3d54108cd77775b45b path: patches/@lifi__sdk.patch @@ -134,10 +138,10 @@ importers: version: 0.1.12(typescript@5.9.3)(zod@4.3.5) '@farcaster/miniapp-sdk': specifier: ^0.2.1 - version: 0.2.1(typescript@5.9.3)(zod@4.3.5) + version: 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@farcaster/miniapp-wagmi-connector': specifier: ^1.1.0 - version: 1.1.0(@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) + version: 1.1.0(@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) '@intercom/intercom-react-native': specifier: ^9.4.0 version: 9.4.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0) @@ -213,6 +217,9 @@ importers: burnt: specifier: ^0.13.0 version: 0.13.0(expo@54.0.31)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0) + comlink: + specifier: ^4.4.2 + version: 4.4.2 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -723,6 +730,9 @@ importers: '@exactly/lib': specifier: ^0.1.0 version: 0.1.0 + '@google-cloud/kms': + specifier: ^5.3.0 + version: 5.3.0 '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.12.0) @@ -759,18 +769,27 @@ importers: '@valibot/to-json-schema': specifier: ^1.5.0 version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + '@valora/viem-account-hsm-gcp': + specifier: ^1.2.16 + version: 1.2.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) async-mutex: specifier: ^0.5.0 version: 0.5.0 + better-auth: + specifier: ^1.4.18 + version: 1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17) bullmq: specifier: ^5.66.5 version: 5.66.5 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 debug: specifier: ^4.4.3 version: 4.4.3 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1) + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) graphql: specifier: ^16.12.0 version: 16.12.0 @@ -841,6 +860,9 @@ importers: '@wagmi/core': specifier: ^3.2.2 version: 3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -1673,6 +1695,27 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -3137,6 +3180,19 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google-cloud/kms@5.3.0': + resolution: {integrity: sha512-OJiV7AXOSDjb4sLtVUoTkCPTVxumktZZUgALBAbQnBpPeTtWfzvwqBunsXi41Zp5N6WjSrf69s6c9/M9PGoyjQ==} + engines: {node: '>=18'} + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -3572,6 +3628,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -3679,6 +3738,10 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} @@ -4105,6 +4168,10 @@ packages: '@pix.js/validator@1.1.0': resolution: {integrity: sha512-NIYcYwuFblA8/cx7YpNdEEujNjKsnA985jsNgIMcYtY2AVUz646IUbisgTgFu7erN7X5eeQGzELgRnFoPcInVw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4133,6 +4200,36 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -5754,6 +5851,10 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -6220,6 +6321,12 @@ packages: peerDependencies: valibot: ^1.2.0 + '@valora/viem-account-hsm-gcp@1.2.16': + resolution: {integrity: sha512-JaxVDEmUHKkJ2ox4yt/4GxKcU1NtHujxW7cux9fHC6rRajdPjxl3HBWwPZ3yqhMFSxfvfdicXuCQhDmqeAXlaw==} + engines: {node: '>=20'} + peerDependencies: + viem: ^2.9.20 + '@vitest/coverage-v8@4.0.17': resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} peerDependencies: @@ -6770,6 +6877,76 @@ packages: peerDependencies: ajv: 4.11.8 - 8 + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -6778,6 +6955,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -6785,6 +6966,12 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -6852,6 +7039,9 @@ packages: resolution: {integrity: sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w==} engines: {node: '>= 0.4.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6944,6 +7134,10 @@ packages: caniuse-lite@1.0.30001765: resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -7006,6 +7200,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -7551,6 +7748,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -7871,9 +8072,15 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + edit-json-file@1.8.1: resolution: {integrity: sha512-x8L381+GwqxQejPipwrUZIyAg5gDQ9tLVwiETOspgXiaQztLsrOm7luBW5+Pe31aNezuzDY79YyzF+7viCRPXA==} @@ -8432,6 +8639,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -8765,6 +8976,10 @@ packages: fengari@0.1.5: resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -8780,6 +8995,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -8896,6 +9114,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -8973,6 +9195,14 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} deprecated: This package is no longer supported. + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -9036,6 +9266,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -9050,6 +9283,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -9111,6 +9349,18 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-gax@5.0.6: + resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -9139,6 +9389,10 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + h3@1.15.5: resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} @@ -9358,6 +9612,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -9780,6 +10038,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -9888,6 +10149,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -9946,6 +10210,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.27: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true @@ -9964,6 +10234,10 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + lan-network@0.1.7: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true @@ -10120,6 +10394,9 @@ packages: lodash._pickbycallback@3.0.0: resolution: {integrity: sha512-DVP27YmN0lB+j/Tgd/+gtxfmW/XihgWpQpHptBuwyp2fD9zEBRwwcnw6Qej16LUV8LRFuTqyoc0i6ON97d/C5w==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -10172,6 +10449,9 @@ packages: resolution: {integrity: sha512-p1Ow0C2dDJYaQBhRHt+HVMP6ELuBm4jYSYNHPMfz0J5wJ9qA6/7oBOlBZBfT1InqguTYcvJzNea5FItDxTcbyw==} hasBin: true + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -10683,6 +10963,9 @@ packages: typescript: optional: true + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -10743,6 +11026,13 @@ packages: nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -10796,6 +11086,11 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -10808,6 +11103,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -10896,6 +11195,10 @@ packages: object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -11344,6 +11647,12 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11425,6 +11734,14 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + protolint@0.56.4: resolution: {integrity: sha512-wrRXaiyNDSzYJ7LBcDnwkWnsRi1uNlFleQp90CsBsh2YvVJEwKXr/c/W9MRYdt+ScpEo8Eg3d60QmVhsZBJu2w==} hasBin: true @@ -11436,6 +11753,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -11966,6 +12286,10 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + retry-request@8.0.2: + resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -11975,6 +12299,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -11986,6 +12314,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -12096,6 +12427,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -12191,6 +12525,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -12354,9 +12694,15 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -12444,6 +12790,9 @@ packages: structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + sturdy-websocket@0.2.1: resolution: {integrity: sha512-NnzSOEKyv4I83qbuKw9ROtJrrT6Z/Xt7I0HiP/e6H6GnpeTDvzwGIGeJ8slai+VwODSHQDooW2CAilJwT9SpRg==} @@ -12532,6 +12881,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -12540,6 +12892,10 @@ packages: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + temp-dir@1.0.0: resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} engines: {node: '>=4'} @@ -12706,6 +13062,9 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -13272,6 +13631,10 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: resolution: {tarball: https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda} version: 0.0.0 @@ -14632,6 +14995,27 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.5) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.1': {} '@bufbuild/buf-darwin-arm64@1.63.0': @@ -16246,7 +16630,7 @@ snapshots: '@farcaster/frame-sdk@0.1.12(typescript@5.9.3)(zod@4.3.5)': dependencies: - '@farcaster/miniapp-sdk': 0.2.1(typescript@5.9.3)(zod@4.3.5) + '@farcaster/miniapp-sdk': 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@farcaster/quick-auth': 0.0.8(typescript@5.9.3) comlink: 4.4.2 eventemitter3: 5.0.4 @@ -16263,7 +16647,7 @@ snapshots: transitivePeerDependencies: - typescript - '@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5)': + '@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5)': dependencies: '@farcaster/miniapp-core': 0.4.1(typescript@5.9.3) '@farcaster/quick-auth': 0.0.6(typescript@5.9.3) @@ -16274,9 +16658,9 @@ snapshots: - typescript - zod - '@farcaster/miniapp-wagmi-connector@1.1.0(@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': + '@farcaster/miniapp-wagmi-connector@1.1.0(@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': dependencies: - '@farcaster/miniapp-sdk': 0.2.1(typescript@5.9.3)(zod@4.3.5) + '@farcaster/miniapp-sdk': 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@wagmi/core': 3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) viem: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) @@ -16325,6 +16709,24 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google-cloud/kms@5.3.0': + dependencies: + google-gax: 5.0.6 + transitivePeerDependencies: + - supports-color + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -16698,6 +17100,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@jsdevtools/ono@7.1.3': {} '@levischuck/tiny-cbor@0.2.11': {} @@ -16885,6 +17289,8 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 @@ -17483,6 +17889,9 @@ snapshots: transitivePeerDependencies: - buffer + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.9': {} @@ -17508,6 +17917,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -18155,7 +18587,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -19911,6 +20343,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tootallnate/once@2.0.0': {} + '@trysound/sax@0.2.0': {} '@tybys/wasm-util@0.10.1': @@ -20440,6 +20874,15 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@valora/viem-account-hsm-gcp@1.2.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': + dependencies: + '@google-cloud/kms': 5.3.0 + '@noble/curves': 1.9.7 + asn1js: 3.0.7 + viem: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.17(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -21254,6 +21697,38 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + better-auth@1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17): + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.5) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + optionalDependencies: + better-sqlite3: 12.6.2 + drizzle-kit: 0.31.8 + drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) + pg: 8.17.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(@vitest/ui@4.0.17)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + better-call@1.1.8(zod@4.3.5): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.5 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -21262,12 +21737,23 @@ snapshots: dependencies: is-windows: 1.0.2 + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birecord@0.1.1: {} bl@4.1.0: @@ -21361,6 +21847,8 @@ snapshots: once: 1.4.0 sliced: 1.0.1 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -21460,6 +21948,8 @@ snapshots: caniuse-lite@1.0.30001765: {} + canonicalize@2.1.0: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -21527,6 +22017,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -22132,6 +22624,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -22317,10 +22811,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.11 pg: 8.17.1 dset@3.1.4: {} @@ -22331,8 +22827,19 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + edit-json-file@1.8.1: dependencies: find-value: 1.0.13 @@ -23293,6 +23800,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} expo-application@7.0.8(expo@54.0.31): @@ -23713,6 +24222,11 @@ snapshots: sprintf-js: 1.1.3 tmp: 0.2.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} figures@3.2.0: @@ -23727,6 +24241,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -23861,6 +24377,10 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -23950,6 +24470,23 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensequence@8.0.8: {} @@ -24005,6 +24542,8 @@ snapshots: getenv@2.0.0: {} + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} gitmojis@3.15.0: {} @@ -24017,6 +24556,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -24096,6 +24644,36 @@ snapshots: globrex@0.1.2: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-gax@5.0.6: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.5.4 + retry-request: 8.0.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} got-fetch@5.1.10(got@12.6.1): @@ -24127,6 +24705,13 @@ snapshots: graphql@16.12.0: {} + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + h3@1.15.5: dependencies: cookie-es: 1.2.2 @@ -24445,6 +25030,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -24864,6 +25457,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -25003,6 +25602,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -25058,6 +25661,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.27: dependencies: commander: 8.3.0 @@ -25072,6 +25686,8 @@ snapshots: klona@2.0.6: {} + kysely@0.28.11: {} + lan-network@0.1.7: {} langium@4.2.1: @@ -25210,6 +25826,8 @@ snapshots: lodash._basefor: 3.0.3 lodash.keysin: 3.0.8 + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.defaults@4.2.0: {} @@ -25259,6 +25877,8 @@ snapshots: split: 0.2.10 through: 2.3.8 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -26317,6 +26937,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mlly@1.8.0: @@ -26380,6 +27002,10 @@ snapshots: dependencies: picocolors: 1.1.1 + nanostores@1.1.0: {} + + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -26420,12 +27046,20 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.3.3: {} node-gyp-build-optional-packages@5.2.2: @@ -26546,6 +27180,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -27046,6 +27682,21 @@ snapshots: dependencies: xtend: 4.0.2 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.86.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -27116,6 +27767,25 @@ snapshots: proto-list@1.2.4: {} + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.5.4 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 25.0.9 + long: 5.3.2 + protolint@0.56.4: dependencies: got: 12.6.1 @@ -27136,6 +27806,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -27806,12 +28481,23 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + retry-request@8.0.2: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.0 + transitivePeerDependencies: + - supports-color + reusify@1.1.0: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + robust-predicates@3.0.2: {} rollup-pluginutils@2.8.2: @@ -27849,6 +28535,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.2 fsevents: 2.3.3 + rou3@0.7.12: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -27995,6 +28683,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -28160,6 +28850,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -28327,8 +29025,14 @@ snapshots: stream-buffers@2.2.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + stream-replace-string@2.0.0: {} + stream-shift@1.0.3: {} + strict-uri-encode@2.0.0: {} string-ts@2.3.1: {} @@ -28439,6 +29143,8 @@ snapshots: structured-headers@0.4.1: {} + stubs@3.0.0: {} + sturdy-websocket@0.2.1: optional: true @@ -28595,6 +29301,13 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -28611,6 +29324,15 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teeny-request@10.1.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + temp-dir@1.0.0: {} temp-dir@2.0.0: {} @@ -28757,6 +29479,10 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -29264,6 +29990,8 @@ snapshots: web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} + webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: {} webauthn-p256@0.0.10: diff --git a/server/api/activity.ts b/server/api/activity.ts index 73a7453c9..9324dd352 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -1,7 +1,7 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { captureException, setUser } from "@sentry/node"; -import { and, arrayOverlaps, eq, inArray } from "drizzle-orm"; +import { arrayOverlaps, eq } from "drizzle-orm"; import { Hono } from "hono"; import { accepts } from "hono/accepts"; import { validator as vValidator } from "hono-openapi/valibot"; @@ -51,7 +51,7 @@ import { decodeWithdraw } from "@exactly/common/ProposalType"; import { Address, Hash, type Hex } from "@exactly/common/validation"; import { effectiveRate, WAD } from "@exactly/lib"; -import database, { cards, credentials, transactions as transactionsSchema } from "../database"; +import database, { cards, credentials, transactions } from "../database"; import auth from "../middleware/auth"; import { collectors as cryptomateCollectors } from "../utils/cryptomate"; import { collectors as pandaCollectors } from "../utils/panda"; @@ -90,7 +90,7 @@ export default new Hono().get( columns: { account: true }, with: { cards: { - columns: {}, + columns: { id: true, lastFour: true }, with: { transactions: { columns: { hashes: true, payload: true } } }, limit: ignore("card") || maturity !== undefined ? 0 : undefined, }, @@ -262,32 +262,27 @@ export default new Hono().get( ].map((blockNumber) => publicClient.getBlock({ blockNumber })), ); const timestamps = new Map(blocks.map(({ number: block, timestamp }) => [block, timestamp])); - let statementCards: string[] = []; - let cardPurchases: typeof credential.cards; - if (!ignore("card") && maturity !== undefined && borrows) { - const hashes = borrows - .entries() - .filter(([_, { events }]) => events.some(({ maturity: m }) => Number(m) === maturity)) - .map(([hash]) => hash) - .toArray(); - const userCards = await database.query.cards - .findMany({ columns: { id: true }, where: eq(cards.credentialId, credentialId) }) - .then((rows) => rows.map(({ id }) => id)); - const statementTransactions = - hashes.length === 0 || userCards.length === 0 - ? [] - : await database.query.transactions.findMany({ - where: and( - arrayOverlaps(transactionsSchema.hashes, hashes), - inArray(transactionsSchema.cardId, userCards), - ), - columns: { cardId: true, hashes: true, payload: true }, + const purchases = + !ignore("card") && borrows && maturity !== undefined + ? await (() => { + const hashes = borrows + .entries() + .filter(([_, { events }]) => events.some(({ maturity: m }) => Number(m) === maturity)) + .map(([hash]) => hash) + .toArray(); + if (hashes.length === 0) return []; + return database.query.cards.findMany({ + where: eq(cards.credentialId, credentialId), + columns: { id: true, lastFour: true }, + with: { + transactions: { + columns: { hashes: true, payload: true }, + where: arrayOverlaps(transactions.hashes, hashes), + }, + }, }); - statementCards = [...new Set(statementTransactions.map(({ cardId }) => cardId))]; - cardPurchases = [{ transactions: statementTransactions }]; - } else { - cardPurchases = credential.cards; - } + })() + : credential.cards; const accept = accepts(c, { header: "Accept", @@ -297,8 +292,8 @@ export default new Hono().get( const pdf = accept === "application/pdf"; const response = [ - ...cardPurchases.flatMap(({ transactions }) => - transactions.map(({ hashes, payload }) => { + ...purchases.flatMap(({ transactions: txs }) => + txs.map(({ hashes, payload }) => { const panda = safeParse(PandaActivity, { ...(payload as object), hashes, @@ -426,20 +421,16 @@ export default new Hono().get( .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); if (maturity !== undefined && pdf) { - if (statementCards.length > 1) return c.json({ code: "multiple cards" }, 400); - const statementCurrency = market(marketUSDCAddress).symbol; - const card = - statementCards.length === 0 - ? undefined - : await database.query.cards.findFirst({ - columns: { lastFour: true }, - where: and(eq(cards.credentialId, credentialId), inArray(cards.id, statementCards)), - }); - const statement = { - maturity, - lastFour: card?.lastFour ?? "", - data: response.flatMap((item): Parameters[0]["data"] => { + const cardLookup = new Map( + purchases.flatMap(({ id, transactions: txs }) => + txs.flatMap(({ hashes }) => hashes.map((hash) => [hash, id] as const)), + ), + ); + const purchasesByCard = Map.groupBy( + response.flatMap((item) => { if (item.type === "panda") { + const cardId = item.operations[0] && cardLookup.get(item.operations[0].transactionHash); + if (!cardId) return []; const installments = item.operations .reduce((accumulator, operation) => { if ("borrow" in operation) { @@ -473,6 +464,7 @@ export default new Hono().get( if (installments.length === 0) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -481,6 +473,8 @@ export default new Hono().get( ]; } if (item.type === "card" && "borrow" in item) { + const cardId = cardLookup.get(item.transactionHash); + if (!cardId) return []; if ("installments" in item.borrow) { const events = borrows?.get(item.transactionHash)?.events; if (!events) return []; @@ -493,6 +487,7 @@ export default new Hono().get( if (installments.length === 0) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -504,6 +499,7 @@ export default new Hono().get( if (!borrow || Number(borrow.maturity) !== maturity) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -511,20 +507,30 @@ export default new Hono().get( }, ]; } - if (item.type === "repay") { - if (item.currency !== statementCurrency) return []; - return [ - { - id: item.id, - timestamp: item.timestamp, - currency: item.currency, - positionAmount: item.positionAmount, - amount: item.amount, - }, - ]; - } return []; }), + ({ cardId }) => cardId, + ); + const statement = { + account: `${account.slice(0, 6)}...${account.slice(-6)}`, + maturity, + cards: purchases + .filter(({ id }) => purchasesByCard.has(id)) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map(({ id, lastFour }) => ({ + id, + lastFour, + purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest), + })), + payments: response + .filter((item) => item.type === "repay") + .filter((repay) => repay.currency === market(marketUSDCAddress).symbol) + .map(({ id, timestamp, amount, positionAmount }) => ({ + id, + timestamp, + amount, + positionAmount, + })), }; return c.body(new Uint8Array(await renderToBuffer(Statement(statement))), 200, { "content-type": "application/pdf", diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index fe2cfb5e2..330f03a89 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -50,6 +50,7 @@ import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; +import validFactories from "../../utils/validFactories"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -250,6 +251,15 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se validatorHook({ code: "bad session" }), ), vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "query", + optional( + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + ), + validatorHook({ code: "bad factory" }), + ), vValidator( "json", variant("method", [ @@ -304,6 +314,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ), async (c) => { const assertion = c.req.valid("json"); + const factory = c.req.valid("query")?.factory ?? undefined; setContext("auth", assertion); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); @@ -329,7 +340,8 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ) { return c.json({ code: "bad authentication", legacy: "bad authentication" }, 400); } - const result = await createCredential(c, assertion.id, { source: c.req.header("Client-Fid") }); + if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); + const result = await createCredential(c, assertion.id, { factory, source: c.req.header("Client-Fid") }); const account = deriveAddress(result.factory, { x: result.x, y: result.y }); const intercomToken = await getIntercomToken(account, result.auth); return c.json( @@ -345,6 +357,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se return c.json({ code: "ouch", legacy: "ouch" }, 500); } } + if (factory && factory !== parse(Address, credential.factory)) return c.json({ code: "bad factory" }, 400); setUser({ id: parse(Address, credential.account) }); let newCounter: number | undefined; diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index acaff073e..a52d24a87 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -45,6 +45,7 @@ import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; +import validFactories from "../../utils/validFactories"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -255,6 +256,15 @@ export default new Hono() validatorHook({ code: "bad session" }), ), vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "query", + optional( + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + ), + validatorHook({ code: "bad factory" }), + ), vValidator( "json", variant("method", [ @@ -304,9 +314,11 @@ export default new Hono() ), async (c) => { const attestation = c.req.valid("json"); + const factory = c.req.valid("query")?.factory ?? undefined; setContext("auth", attestation); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); + if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); const challenge = await redis.getdel(sessionId); if (!challenge) return c.json({ code: "no registration", legacy: "no registration" }, 400); @@ -360,7 +372,11 @@ export default new Hono() } try { - const result = await createCredential(c, attestation.id, { webauthn, source: c.req.header("Client-Fid") }); + const result = await createCredential(c, attestation.id, { + factory, + webauthn, + source: c.req.header("Client-Fid"), + }); const account = deriveAddress(result.factory, { x: result.x, y: result.y }); const intercomToken = await getIntercomToken(account, new Date(Date.now() + AUTH_EXPIRY)); return c.json( diff --git a/server/api/card.ts b/server/api/card.ts index 2a7479eea..df1b92fc2 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getApplicationStatus, + getCard, + getPIN, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -47,6 +57,7 @@ function createMutex(credentialId: string) { } const CardResponse = object({ + cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), displayName: pipe(string(), metadata({ examples: ["John Doe"] })), encryptedPan: object({ data: string(), iv: string() }), encryptedCvc: object({ data: string(), iv: string() }), @@ -68,13 +79,20 @@ const CardResponse = object({ "perAuthorization", ]), }), - productId: pipe(string(), metadata({ examples: ["402"] })), + productId: pipe( + picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + ), }); const CreatedCardResponse = object({ lastFour: pipe(string(), metadata({ examples: ["1234"] })), + cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), status: pipe(picklist(["ACTIVE", "FROZEN"]), metadata({ examples: ["ACTIVE", "FROZEN"] })), - productId: pipe(string(), metadata({ examples: ["402"] })), + productId: pipe( + picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + ), }); const UpdateCard = union([ @@ -250,6 +268,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str { ...pan, ...pin, + cardId: id, displayName: `${user.firstName} ${user.lastName}`, expirationMonth, expirationYear, @@ -258,7 +277,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str provider: "panda" as const, status, limit, - productId, + productId: parse(CardResponse.entries.productId, productId), } satisfies InferOutput, 200, ); @@ -292,7 +311,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str 403: { description: "Forbidden", content: { - "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + union([object({ code: literal("no panda") }), object({ code: literal("kyc not approved") })]), + { errorMode: "ignore" }, + ), + }, }, }, }, @@ -317,6 +341,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const kyc = await getApplicationStatus(credential.pandaId); + if (kyc.applicationStatus !== "approved") { + return c.json({ code: "kyc not approved" }, 403); + } let isUpgradeFromPlatinum = credential.cards.some( ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, @@ -383,9 +411,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str }).catch((error: unknown) => captureException(error)); } return c.json( - { lastFour: card.last4, status: "ACTIVE", productId: SIGNATURE_PRODUCT_ID } satisfies InferOutput< - typeof CreatedCardResponse - >, + { + lastFour: card.last4, + status: "ACTIVE", + cardId: card.id, + productId: SIGNATURE_PRODUCT_ID, + } satisfies InferOutput, 200, ); } catch (error) { @@ -462,12 +493,12 @@ async function encryptPIN(pin: string) { secretKeyBase64Buffer, ); const sessionId = secretKeyBase64BufferEncrypted.toString("base64"); - + const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-128-gcm", Buffer.from(secret, "hex"), iv); const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); - + return { data: Buffer.concat([encrypted, authTag]).toString("base64"), iv: iv.toString("base64"), @@ -491,6 +522,7 @@ async function encryptPIN(pin: string) { object({ code: literal("bad request") }), object({ code: literal("already set"), mode: number() }), object({ code: literal("already set"), status: picklist(["ACTIVE", "DELETED", "FROZEN"]) }), + object({ code: literal("weak pin") }), ]), { errorMode: "ignore" }, ), @@ -500,7 +532,15 @@ async function encryptPIN(pin: string) { 404: { description: "Not found", content: { - "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + object({ + code: pipe(literal("no card"), metadata({ examples: ["no card"] })), + legacy: pipe(literal("no card found"), metadata({ examples: ["no card found"] })), + }), + { errorMode: "ignore" }, + ), + }, }, }, }, @@ -553,7 +593,14 @@ async function encryptPIN(pin: string) { } case "pin": { const { sessionId, data, iv } = patch; - await setPIN(card.id, sessionId, { data, iv }); + try { + await setPIN(card.id, sessionId, { data, iv }); + } catch (error) { + if (error instanceof Error && error.message.includes("Weak PIN")) { + return c.json({ code: "weak pin" }, 400); + } + throw error; + } return c.json({ data, iv } satisfies InferOutput, 200); } } diff --git a/server/api/index.ts b/server/api/index.ts index ec7c91328..b1af0a2cc 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -10,6 +10,7 @@ import kyc from "./kyc"; import passkey from "./passkey"; import pax from "./pax"; import ramp from "./ramp"; +import webhook from "./webhook"; import appOrigin from "../utils/appOrigin"; const api = new Hono() @@ -26,7 +27,8 @@ const api = new Hono() .route("/kyc", kyc) .route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", pax) - .route("/ramp", ramp); + .route("/ramp", ramp) + .route("/webhook", webhook); export default api; export type ExaAPI = typeof api; diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 223f7406a..92671cdc6 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,22 +1,33 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; +import canonicalize from "canonicalize"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { validator as vValidator } from "hono-openapi/valibot"; -import { literal, object, optional, parse, picklist, string } from "valibot"; -import { getAddress } from "viem"; +import * as honoOpenapi from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; +import { getAddress, sha256, verifyMessage } from "viem"; +import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; -import { +import chain, { exaAccountFactoryAddress, exaPluginAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import database, { credentials } from "../database/index"; +import database, { credentials, walletAddresses } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; +import { + SubmitApplicationRequest as Application, + UpdateApplicationRequest as ApplicationUpdate, + getApplicationStatus, + KycError, + submitApplication, + updateApplication, +} from "../utils/panda"; import { createInquiry, CRYPTOMATE_TEMPLATE, @@ -33,6 +44,26 @@ import validatorHook from "../utils/validatorHook"; const debug = createDebug("exa:kyc"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const KYCStatusResponse = object({ + code: pipe(string(), metadata({ examples: ["ok"] })), + legacy: pipe(string(), metadata({ examples: ["ok"] })), + status: pipe(string(), metadata({ examples: ["approved", "rejected"] })), + reason: pipe(string(), metadata({ examples: ["", "BAD_SELFIE"] })), +}); + +const BadRequestCodes = { + ALREADY_STARTED: "already started", + NOT_STARTED: "not started", + BAD_REQUEST: "bad request", +} as const; + +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} + export default new Hono() .get( "/", @@ -184,6 +215,370 @@ export default new Hono() throw new Error("Unknown inquiry status"); } }, + ) + .post( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Submit KYC application", + description: ` +Submit information for KYC application. + +**Encrypted kyc payload** + +When the header has encrypted=true, the payload should be encrypted. + +The steps to encrypt are: + +1. Generate AES Key: Create a random 256-bit AES key +2. Encrypt Payload: Use AES-256-GCM to encrypt your KYC JSON data +3. Encrypt AES Key: Use Rain-provided RSA public key with OAEP padding +4. Encode Components: Base64-encode all encrypted components +5. Set Header: Include encrypted: "true" header in your request +6. Submit Request + +KYC Encryption Public Key for sandbox is: + +\`\`\` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +\`\`\` + +KYC Encryption Public Key for production needs to be provided. + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +**Payload structure before encryption** + +1. Personal information (name, date of birth, address) +2. Identity verification documents +3. Compliance information (occupation, income, etc.) +4. Terms of service acceptance + +Here's the markdown table with object notation for nested fields: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| email | string | user@domain.com | | +| lastName | string | Doe | | +| firstName | string | John | | +| nationalId | string | 123456789 | | +| birthDate | string | 1970-01-01 | | +| countryOfIssue | string | US | | +| phoneCountryCode | string | 1 | | +| phoneNumber | string | 5551234567 | | +| address.line1 | string | 123 Main Street | | +| address.line2 | string | Apt 4B | | +| address.city | string | New York | | +| address.region | string | NY | | +| address.postalCode | string | 10001 | | +| address.countryCode | string | US | | +| ipAddress | string | 192.168.1.100 | | +| occupation | string | 11-1011 | Ask for the mandatory occupation codes | +| annualSalary | string | 75000 | | +| accountPurpose | string | Personal Banking | | +| expectedMonthlyVolume | string | 5000 | | +| isTermsOfServiceAccepted | boolean | true | | + +**Authentication and organization verification** + +The exa account needs to be authenticated but also a member of the organization that submit the KYC application needs to probe that +belong to the organization and needs to have *kyc* permission, every owner and admin of an organization has this permission. + +To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: + +"I apply for KYC approval on behalf of address [checksum address] with payload hash [hash]"; + +The hash is sha256(encryptedPayload.ciphertext) + +The siwe message will be: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| verify.message | string | SIWE message that includes the statement | | +| verify.signature | Hex | signature of the message | | +| verify.walletAddress | Address | address of the member of the organization that signed the message | | +| verify.chainId | number | 11155420 | | + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +Note that the member of the organization must be created, the organization must exist and the member must be added as admin by another admin or owner. + +Working example about how to login is [here](../../../organization-authentication/#siwe-authentication) + +The admin should add a member using [addMember method](https://www.better-auth.com/docs/plugins/organization#add-member). +`, + tags: ["KYC"], + responses: { + 200: { + description: "KYC application submitted successfully", + content: { + "application/json": { + schema: resolver(object({ status: string() }), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: picklist(["invalid encryption", "no account", "bad chain"]), message: string() }), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { + errorMode: "ignore", + }, + ), + }, + }, + }, + 401: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ + code: literal("invalid payload"), + message: string(), + }), + object({ + code: string(), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + 409: { + description: "Conflict", + content: { + "application/json": { + schema: resolver(object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), { errorMode: "ignore" }), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver( + object({ + code: picklist(["no permission", "no organization"]), + message: optional(string()), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", Application, validatorHook({ debug })), + vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), + async (c) => { + const payload = c.req.valid("json"); + const { message, signature, walletAddress: address } = payload.verify; + + if (!(await verifyMessage({ address, message, signature }))) { + return c.json({ code: "no permission", message: "invalid signature" }, 403); + } + const account = await database.query.walletAddresses.findFirst({ + where: eq(walletAddresses.address, address), + with: { + user: { + columns: { id: true }, + with: { + members: { + columns: { role: true }, + with: { organization: { columns: { id: true, role: true } } }, + }, + }, + }, + }, + }); + + if (!account) return c.json({ code: "no account", message: `no account found for address ${address}` }, 400); + const member = account.user.members[0]; + if (!member) return c.json({ code: "no organization" }, 403); + if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); + if (member.organization.role !== "kyc") return c.json({ code: "no permission" }, 403); + + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + + const siweMessage = parseSiweMessage(payload.verify.message); + + if (siweMessage.chainId !== chain.id) + return c.json({ code: "bad chain", message: `expected ${chain.id} but got ${siweMessage.chainId}` }, 400); + + const { verify, ...body } = payload; + const hash = + "ciphertext" in body + ? sha256(Buffer.from(body.ciphertext, "base64")) + : sha256(Buffer.from(JSON.stringify(canonicalize(body)), "utf8")); + const expected = `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}`; + if (siweMessage.statement !== expected) { + return c.json( + { + code: "no permission", + message: `invalid statement, expected: [${expected}] but got [${siweMessage.statement}]`, + }, + 403, + ); + } + + if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + + try { + const application = await submitApplication(payload, c.req.header("encrypted") === "true"); + await database + .update(credentials) + .set({ pandaId: application.id, source: member.organization.id }) + .where(eq(credentials.id, credentialId)); + return c.json({ status: application.applicationStatus }, 200); + } catch (error) { + if (error instanceof KycError) { + switch (error.statusCode) { + case 400: + return c.json({ code: "invalid encryption", message: error.message }, 400); + case 401: + return c.json({ code: "invalid payload", message: error.message }, 401); + default: + return c.json({ code: error.message }, 401); + } + } + throw error; + } + }, + ) + .patch( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Update KYC application", + description: "Update the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application updated successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", ApplicationUpdate, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + await updateApplication(credential.pandaId, payload); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .get( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Get KYC application status", + description: "Get the status of the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application status", + content: { + "application/json": { + schema: resolver(KYCStatusResponse, { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + const status = await getApplicationStatus(credential.pandaId); + return c.json( + { code: "ok", legacy: "ok", status: status.applicationStatus, reason: status.applicationReason ?? "unknown" }, + 200, + ); + }, ); async function isLegacy( diff --git a/server/api/webhook.ts b/server/api/webhook.ts new file mode 100644 index 000000000..6e3f989fd --- /dev/null +++ b/server/api/webhook.ts @@ -0,0 +1,271 @@ +import { Mutex } from "async-mutex"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { randomBytes } from "node:crypto"; +import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union } from "valibot"; + +import database, { sources } from "../database"; +import authValidator from "../middleware/auth"; +import auth from "../utils/auth"; +import validatorHook from "../utils/validatorHook"; + +const BaseWebhook = object({ + url: string(), + transaction: optional( + object({ created: optional(string()), updated: optional(string()), completed: optional(string()) }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), +}); + +const Webhook = object({ ...BaseWebhook.entries, secret: string() }); + +const WebhookConfig = object({ type: picklist(["uphold"]), webhooks: record(string(), Webhook) }); + +const mutexes = new Map(); +function createMutex(organizationId: string) { + const mutex = new Mutex(); + mutexes.set(organizationId, mutex); + return mutex; +} + +export default new Hono() + .get( + "/", + authValidator(), + describeRoute({ + summary: "Get webhook information", + description: `Retrieve the organization's webhook information for an authenticated user the belongs to the organization. Only owner and admin roles can read the webhook information.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook information", + content: { "application/json": { schema: resolver(record(string(), BaseWebhook), { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const organizations = await auth.api.listOrganizations({ + headers: c.req.raw.headers, + }); + const organizationId = organizations[0]?.id; + if (!organizationId) return c.json({ code: "no organization" }, 403); + + const { success: canRead } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId, permissions: { webhook: ["read"] } }, + }); + if (!canRead) return c.json({ code: "no permission" }, 403); + + const source = await database.query.sources.findFirst({ + where: eq(sources.id, organizationId), + }); + if (!source) return c.json({}, 200); + const config = parse( + object({ ...WebhookConfig.entries, webhooks: record(string(), BaseWebhook) }), + source.config, + ); + return c.json(config.webhooks, 200); + }, + ) + .post( + "/", + authValidator(), + describeRoute({ + summary: "Creates or updates a webhook", + description: `it creates a new webhook if it doesn't exist or updates the existing webhook if it does. Only owner and admin roles can create or update a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook created or updated", + content: { "application/json": { schema: resolver(Webhook, { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + vValidator( + "json", + object({ + name: string(), + url: string(), + transaction: optional( + object({ + created: optional(string()), + updated: optional(string()), + completed: optional(string()), + }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), + }), + validatorHook(), + ), + async (c) => { + const { name, ...payload } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + const { success: canCreate } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["create"] } }, + }); + if (!canCreate) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const webhook = { ...payload, secret: config.webhooks[name]?.secret ?? randomBytes(16).toString("hex") }; + await database + .update(sources) + .set({ config: { ...config, webhooks: { ...config.webhooks, [name]: webhook } } }) + .where(eq(sources.id, id)); + return c.json(webhook, 200); + } else { + const webhook = { ...payload, secret: randomBytes(16).toString("hex") }; + await database.insert(sources).values({ id, config: { type: "uphold", webhooks: { [name]: webhook } } }); + return c.json(webhook, 200); + } + }); + }, + ) + .delete( + "/", + authValidator(), + vValidator("json", object({ name: string() }), validatorHook()), + describeRoute({ + summary: "Deletes a webhook", + description: `it deletes the webhook with the given name. Only owner and admin roles can delete a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook deleted", + content: { + "application/json": { schema: resolver(object({ code: literal("ok") }), { errorMode: "ignore" }) }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { name } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + + const { success: canDelete } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["delete"] } }, + }); + if (!canDelete) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const { [name]: _, ...remainingWebhooks } = config.webhooks; + await database + .update(sources) + .set({ config: { ...config, webhooks: remainingWebhooks } }) + .where(eq(sources.id, id)); + } + return c.json({ code: "ok" }, 200); + }); + }, + ); diff --git a/server/database/index.ts b/server/database/index.ts index d288cb2f3..32f57021e 100644 --- a/server/database/index.ts +++ b/server/database/index.ts @@ -1,3 +1,4 @@ +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; import { env } from "node:process"; @@ -5,6 +6,22 @@ import * as schema from "./schema"; if (!env.POSTGRES_URL) throw new Error("missing postgres url"); -export default drizzle(env.POSTGRES_URL, { schema }); +const database = drizzle(env.POSTGRES_URL, { schema }); + +export default database; export * from "./schema"; + +export const authAdapter = drizzleAdapter(database, { + provider: "pg", + schema: { + user: schema.users, + session: schema.sessions, + account: schema.authenticators, + verification: schema.verifications, + walletAddress: schema.walletAddresses, + organization: schema.organizations, + member: schema.members, + invitation: schema.invitations, + }, +}); diff --git a/server/database/schema.ts b/server/database/schema.ts index 711960f71..747c3d46b 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -1,8 +1,10 @@ import { relations } from "drizzle-orm"; import { bigint, + boolean, char, customType, + index, integer, jsonb, pgEnum, @@ -11,6 +13,7 @@ import { primaryKey, serial, text, + timestamp, uniqueIndex, } from "drizzle-orm/pg-core"; @@ -57,7 +60,15 @@ export const transactions = pgTable("transactions", { payload: jsonb("payload").notNull(), }); -export const credentialsRelations = relations(credentials, ({ many }) => ({ cards: many(cards) })); +export const sources = pgTable("sources", { + id: text("id").primaryKey(), + config: jsonb("config").notNull(), +}); + +export const credentialsRelations = relations(credentials, ({ many, one }) => ({ + cards: many(cards), + source: one(sources, { fields: [credentials.source], references: [sources.id] }), +})); export const cardsRelations = relations(cards, ({ many, one }) => ({ credential: one(credentials, { fields: [cards.credentialId], references: [credentials.id] }), @@ -99,3 +110,197 @@ export const exaPlugins = substreams.table( }, ({ address, account }) => [primaryKey({ columns: [address, account] })], ); + +export const sourcesRelations = relations(sources, ({ many }) => ({ credential: many(credentials) })); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const sessions = pgTable( + "sessions", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("sessions_user_idx").on(table.userId)], +); + +export const authenticators = pgTable( + "authenticators", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("authenticators_user_idx").on(table.userId)], +); + +export const verifications = pgTable( + "verifications", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verifications_identifier_idx").on(table.identifier)], +); + +export const walletAddresses = pgTable( + "wallet_addresses", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + address: text("address").notNull(), + chainId: integer("chain_id").notNull(), + isPrimary: boolean("is_primary").default(false), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("wallet_addresses_user_idx").on(table.userId)], +); + +export const organizations = pgTable("organizations", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), + role: text("role"), +}); + +export const members = pgTable( + "members", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("members_organization_idx").on(table.organizationId), index("members_user_idx").on(table.userId)], +); + +export const invitations = pgTable( + "invitations", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitations_organization_idx").on(table.organizationId), + index("invitations_email_idx").on(table.email), + ], +); + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + authenticators: many(authenticators), + walletAddresses: many(walletAddresses), + members: many(members), + invitations: many(invitations), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + +export const authenticatorsRelations = relations(authenticators, ({ one }) => ({ + user: one(users, { + fields: [authenticators.userId], + references: [users.id], + }), +})); + +export const walletAddressesRelations = relations(walletAddresses, ({ one }) => ({ + user: one(users, { + fields: [walletAddresses.userId], + references: [users.id], + }), +})); + +export const organizationsRelations = relations(organizations, ({ many }) => ({ + members: many(members), + invitations: many(invitations), +})); + +export const membersRelations = relations(members, ({ one }) => ({ + organization: one(organizations, { + fields: [members.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [members.userId], + references: [users.id], + }), +})); + +export const invitationsRelations = relations(invitations, ({ one }) => ({ + organization: one(organizations, { + fields: [invitations.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [invitations.inviterId], + references: [users.id], + }), +})); diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index 4500f0736..514a701d6 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -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, hexToBigInt, withRetry } from "viem"; +import { bytesToBigInt, hexToBigInt } from "viem"; import { - auditorAbi, exaAccountFactoryAbi, - exaPluginAbi, exaPreviewerAbi, exaPreviewerAddress, - marketAbi, - upgradeableModularAccountAbi, wethAddress, } from "@exactly/common/generated/chain"; import { Address, Hash, Hex } from "@exactly/common/validation"; @@ -95,7 +91,7 @@ export default new Hono().post( category !== "erc1155" && (rawContract?.rawValue && rawContract.rawValue !== "0x" ? hexToBigInt(rawContract.rawValue) > 0n : !!value), ); - const accounts = await database.query.credentials + const accountLookup = await database.query.credentials .findMany({ columns: { account: true, publicKey: true, factory: true }, where: inArray(credentials.account, [...new Set(transfers.map(({ toAddress }) => toAddress))]), @@ -113,9 +109,10 @@ export default new Hono().post( .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) .then((p) => new Map(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)]))); const markets = new Set(marketsByAsset.values()); - const pokes = new Map; factory: Address; publicKey: Uint8Array }>(); + + const accounts = new Set
(); for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) { - if (!accounts[account]) continue; + if (!accountLookup[account]) continue; if (rawContract?.address && markets.has(rawContract.address)) continue; const asset = rawContract?.address ?? ETH; const underlying = asset === ETH ? WETH : asset; @@ -126,132 +123,78 @@ 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]) }); - } + accounts.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 - }), - }, - { ignore: ["NoBalance()"] }, - ) - .then((receipt) => { - if (receipt) return receipt; - throw new Error("NoBalance()"); - }), + [...accounts] + .flatMap((account) => { + const info = accountLookup[account]; + return info ? [[account, info] as const] : []; + }) + .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 }) => { - if (error instanceof Error && error.message === "NoBalance()") return true; - captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); - return true; - }, + address: factory, + functionName: "createAccount", + args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]], + abi: exaAccountFactoryAbi, }, - ), - ), - ); - for (const result of results) { - if (result.status === "fulfilled") continue; - if (result.reason instanceof Error && result.reason.message === "NoBalance()") { - withScope((captureScope) => { - 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; + ); + track({ event: "AccountFunded", userId: account }); + } catch (error: unknown) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" }); + throw error; + } } - 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"]), + await keeper + .poke(account, { ignore: [`NotAllowed(${account})`] }) + .catch((error: unknown) => captureException(error)); + 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"]), + }, }, - }, - }); - 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) 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 }); + }, + ), ), ), ), - ), ) .then((results) => { let status: SpanStatus = { code: SPAN_STATUS_OK }; diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 992c60130..3ee8c320e 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -13,6 +13,7 @@ import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; +import { createHmac } from "node:crypto"; import * as v from "valibot"; import { BaseError, @@ -28,9 +29,12 @@ import { padHex, RawContractError, toBytes, + withRetry, zeroHash, + type TransactionReceipt, } from "viem"; +import domain from "@exactly/common/domain"; import { auditorAbi, exaPluginAbi, @@ -66,6 +70,9 @@ import type { UnofficialStatusCode } from "hono/utils/http-status"; const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const debugWebhook = createDebug("exa:webhook"); +Object.assign(debugWebhook, { inspectOpts: { depth: undefined } }); + const BaseTransaction = v.object({ id: v.string(), type: v.literal("spend"), @@ -81,7 +88,7 @@ const BaseTransaction = v.object({ merchantCategory: v.nullish(v.string()), merchantCategoryCode: v.string(), merchantName: v.string(), - merchantId: v.optional(v.string()), + merchantId: v.nullish(v.string()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), authorizationMethod: v.optional(v.string()), @@ -100,6 +107,7 @@ const Transaction = v.variant("action", [ ...BaseTransaction.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.optional(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -114,7 +122,10 @@ const Transaction = v.variant("action", [ authorizationUpdateAmount: v.number(), authorizedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.picklist(["declined", "pending", "reversed"]), - declinedReason: v.optional(v.string()), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -143,6 +154,10 @@ const Transaction = v.variant("action", [ authorizedAt: v.pipe(v.string(), v.isoTimestamp()), postedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -203,7 +218,16 @@ const Payload = v.variant("resource", [ action: v.literal("updated"), body: v.object({ applicationReason: v.string(), - applicationStatus: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), firstName: v.string(), id: v.string(), isActive: v.boolean(), @@ -241,6 +265,9 @@ export default new Hono().post( where: eq(credentials.pandaId, pandaId), }); if (user) setUser({ id: user.account }); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); } return c.json({ code: "ok" }); } @@ -269,8 +296,8 @@ export default new Hono().post( type: payload.body.spend.amount < 0 ? "return" : "purchase", merchant: { mcc: payload.body.spend.merchantCategoryCode, - id: payload.body.spend.merchantId, name: payload.body.spend.merchantName, + ...(payload.body.spend.merchantId && { id: payload.body.spend.merchantId }), }, terminal: { type: payload.body.spend.authorizationMethod }, address: { countryCode: payload.body.spend.merchantCountry }, @@ -524,6 +551,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); sendPushNotification({ @@ -616,8 +647,8 @@ export default new Hono().post( where: eq(cards.id, payload.body.spend.cardId), with: { credential: { columns: { account: true, id: true } } }, }); - if (!card) return c.json({ code: "card not found" }, 404); + const account = v.parse(Address, card.credential.account); setUser({ id: account }); @@ -637,9 +668,12 @@ export default new Hono().post( feedback: { type: "authorization", status: "network_declined", - reason: payload.body.spend.declinedReason, + reason: payload.body.spend.declinedReason ?? "unknown", }, }).catch((error: unknown) => captureException(error, { level: "error" })); + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); return c.json({ code: "ok" }); } if (payload.body.spend.amount < 0) { @@ -650,6 +684,10 @@ export default new Hono().post( feedback: { type: "authorization", status: "approved" }, }).catch((error: unknown) => captureException(error, { level: "error" })); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } if (payload.body.spend.status !== "pending" && payload.action !== "completed") return c.json({ code: "ok" }); @@ -686,6 +724,11 @@ export default new Hono().post( : { type: "settlement", status: "settled" }), }, }).catch((error: unknown) => captureException(error, { level: "error" })); + + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } try { @@ -736,6 +779,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); @@ -1150,3 +1197,291 @@ const TransactionPayload = v.object( { bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") }, "invalid transaction payload", ); + +async function publish(payload: v.InferOutput, receipt?: TransactionReceipt) { + if (payload.resource === "transaction" && payload.action === "requested") return; + if (receipt?.status === "reverted") return; + if (payload.resource === "dispute") return; + if (payload.resource === "card" && payload.action === "notification") return; + + async function sendWebhook(webhookPayload: v.InferOutput, url: string, secret: string) { + try { + const result = await withRetry( + async () => { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Signature: createHmac("sha256", secret).update(JSON.stringify(webhookPayload)).digest("hex"), + }, + body: JSON.stringify(webhookPayload), + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) + throw new Error("WebhookFailed", { + cause: { + code: response.status, + response: await response.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), + payload: webhookPayload, + }, + }); + return response; + }, + { + delay: ({ count }) => Math.trunc(1 << count) * 500, + retryCount: domain === "base-sepolia.exactly.app" ? 3 : 20, + shouldRetry: ({ error }) => { + if (error instanceof Error) { + return error.message === "WebhookFailed" || error.name === "TimeoutError"; + } + return false; + }, + }, + ); + debugWebhook("%j", { + code: result.status, + response: await result.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), + payload: webhookPayload, + }); + } catch (error) { + if (error instanceof Error) { + if (error.message === "WebhookFailed") { + debugWebhook("%j", error.cause); + } else { + debugWebhook("%j", { error: error.message, payload: webhookPayload }); + } + } + throw error; + } + } + + const timestamp = new Date().toISOString(); + const user = await database.query.credentials.findFirst({ + columns: { id: true, source: true }, + with: { source: { columns: { config: true } } }, + where: eq( + credentials.pandaId, + (() => { + switch (payload.resource) { + case "card": + return payload.body.userId; + case "user": + return payload.body.id; + case "transaction": + return payload.body.spend.userId; + } + })(), + ), + }); + + if (!user?.source) return; + const config = v.parse(webhookConfig, user.source.config); + await Promise.allSettled( + Object.values(config.webhooks).map(async (webhook) => { + switch (payload.resource) { + case "user": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { ...payload.body, credentialId: user.id }, + }), + webhook.user?.[payload.action] ?? webhook.url, + webhook.secret, + ); + case "card": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { + ...payload.body, + status: { active: "ACTIVE", locked: "FROZEN", canceled: "DELETED", notActivated: "INACTIVE" }[ + payload.body.status + ], + }, + }), + webhook.card?.[payload.action] ?? webhook.url, + webhook.secret, + ); + case "transaction": + return sendWebhook( + v.parse(Webhook, { + ...payload, + ...(receipt && { receipt }), + timestamp, + ...(payload.action !== "updated" && + payload.body.spend.currency !== payload.body.spend.localCurrency && { + body: { + ...payload.body, + spend: { ...payload.body.spend, exchangeRate: payload.body.spend.exchangeRate }, + }, + }), + }), + webhook.transaction?.[payload.action] ?? webhook.url, + webhook.secret, + ); + } + }), + ).then((results) => { + for (const result of results) { + if (result.status === "rejected") captureException(result.reason, { level: "error" }); + } + }); +} + +const BaseWebhook = v.object({ + id: v.string(), + type: v.literal("spend"), + spend: v.object({ + amount: v.number(), + currency: v.literal("usd"), + cardId: v.string(), + localAmount: v.number(), + localCurrency: v.pipe(v.string(), v.length(3)), + merchantCity: v.nullish(v.pipe(v.string(), v.trim())), + merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategoryCode: v.string(), + merchantName: v.pipe(v.string(), v.trim()), + authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), + authorizedAmount: v.nullish(v.number()), + merchantId: v.nullish(v.string()), + }), +}); + +const Receipt = v.pipe( + v.object({ blockNumber: v.bigint(), transactionHash: v.string() }), + v.transform((r) => { + return { ...r, blockNumber: Number(r.blockNumber) }; + }), +); + +const Webhook = v.variant("resource", [ + v.variant("action", [ + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("created"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + status: v.picklist(["pending", "declined"]), + declinedReason: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("updated"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizationUpdateAmount: v.number(), + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.picklist(["declined", "pending", "reversed"]), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("completed"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), + }), + }), + }), + ]), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("card"), + action: v.literal("updated"), + body: v.object({ + id: v.string(), + last4: v.pipe(v.string(), v.length(4)), + limit: v.object({ + amount: v.number(), + frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), + }), + status: v.picklist(["ACTIVE", "FROZEN", "DELETED"]), + tokenWallets: v.nullish(v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))])), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("user"), + action: v.literal("updated"), + body: v.object({ + credentialId: v.string(), + applicationReason: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), + isActive: v.boolean(), + }), + }), +]); + +const webhookConfig = v.object({ + type: v.picklist(["uphold"]), + webhooks: v.record( + v.string(), + v.object({ + url: v.string(), + secret: v.string(), + transaction: v.optional( + v.object({ + created: v.optional(v.string()), + updated: v.optional(v.string()), + completed: v.optional(v.string()), + }), + ), + card: v.optional(v.object({ updated: v.optional(v.string()) })), + user: v.optional(v.object({ updated: v.optional(v.string()) })), + }), + ), +}); diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index a3f0c130a..507e3fc52 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -13,16 +13,21 @@ import { nullable, object, optional, + parse, pipe, safeParse, string, transform, union, } from "valibot"; +import { withRetry } from "viem"; +import { firewallAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; +import allower from "../utils/allower"; +import keeper from "../utils/keeper"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; @@ -30,6 +35,16 @@ import { customer } from "../utils/sardine"; import validatorHook from "../utils/validatorHook"; import type { InferOutput } from "valibot"; + +let allowerPromise: ReturnType | undefined; +function getAllower() { + allowerPromise ??= allower().catch((error: unknown) => { + allowerPromise = undefined; + throw error; + }); + return allowerPromise; +} + const Session = pipe( object({ type: literal("inquiry-session"), @@ -273,6 +288,40 @@ export default new Hono().post( if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200); } + const account = parse(Address, credential.account); + if (firewallAddress) { + try { + await getAllower().then((client) => client.allow(account, { ignore: [`AlreadyAllowed(${account})`] })); + } catch (error: unknown) { + captureException(error, { level: "error" }); + return c.json({ code: "firewall error" }, 500); + } + withRetry( + () => + keeper.poke(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }), + { + retryCount: 10, + delay: ({ count }) => Math.trunc(1 << count) * 500, + }, + ).catch((error: unknown) => captureException(error, { level: "error" })); + } + + 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), + product: "travel insurance", + }).catch((error: unknown) => captureException(error, { level: "error" })); + // TODO implement error handling to return 200 if event should not be retried const { id } = await createUser({ accountPurpose: fields.accountPurpose.value, @@ -289,26 +338,6 @@ 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) { - 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), - product: "travel insurance", - }).catch((error: unknown) => { - captureException(error, { level: "error", extra: { pandaId: id, referenceId } }); - }); - } else { - captureException(new Error("invalid account address"), { - extra: { pandaId: id, referenceId, account: credential.account }, - level: "error", - }); - } addDocument(referenceId, { id_class: { value: fields.identificationClass.value }, id_number: { value: fields.identificationNumber.value }, diff --git a/server/index.ts b/server/index.ts index 4dc9c5aec..2f216bdbe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,7 @@ import panda from "./hooks/panda"; import persona from "./hooks/persona"; import androidFingerprints from "./utils/android/fingerprints"; import appOrigin from "./utils/appOrigin"; +import auth from "./utils/auth"; import { closeAndFlush as closeSegment } from "./utils/segment"; import type { UnofficialStatusCode } from "hono/utils/http-status"; @@ -77,6 +78,7 @@ app.get("/.well-known/farcaster.json", (c) => ), payload: isoBase64URL.fromUTF8String(`{"domain":"${domain}"}`), signature: { + "base.exactly.app": "0lyEaPuI4Z8Nrc1Yq9rqdPbsimYLnOzMYwv8z8GjwgUCzm4bGx/C1KylzUafPqS9bmtL/iaj0eNU1+MFZUXRGRs=", "web.exactly.app": "MHg1NDJkZTQ0ZGNkOThlMTBmMGI4NWMwY2I4YjU0ODliNTBlYWViYWY2YzE1YTk3NGVkNzk4NTY4ZmE2NDhiY2M2MDhlNWQ4NzliYTQ5M2E3NjhiMmQzYmM0YWZkN2U0ODNkMjQ1MDkxM2RjZDdlNTIzZWRhMzRkN2VlYjc0NmQ3ZjFi", "sandbox.exactly.app": @@ -306,6 +308,8 @@ app.onError((error, c) => { return c.json({ code: "unexpected error", legacy: "unexpected error" }, 555 as UnofficialStatusCode); }); +app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); + export default app; const server = serve(app); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 45c9b5a33..aa423f5cd 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,7 @@ import { getSignedCookie } from "hono/cookie"; import { createMiddleware } from "hono/factory"; +import betterAuth from "../utils/auth"; import authSecret from "../utils/authSecret"; import type { BlankInput, Env, Input } from "hono/types"; @@ -8,7 +9,14 @@ import type { BlankInput, Env, Input } from "hono/types"; export default function auth() { return createMiddleware(async (c, next) => { const credentialId = await getSignedCookie(c, authSecret, "credential_id"); - if (!credentialId) return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + if (!credentialId) { + const session = await betterAuth.api.getSession({ headers: c.req.raw.headers }); + if (session) { + await next(); + return; + } + return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + } c.req.addValidatedData("cookie", { credentialId }); await next(); }); diff --git a/server/package.json b/server/package.json index f5a438faf..0c7291f57 100644 --- a/server/package.json +++ b/server/package.json @@ -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", @@ -44,8 +45,11 @@ "@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", + "better-auth": "^1.4.18", + "canonicalize": "^2.1.0", "debug": "^4.4.3", "drizzle-orm": "^0.45.1", "graphql": "^16.12.0", @@ -73,6 +77,7 @@ "@vitest/coverage-v8": "^4.0.17", "@vitest/ui": "^4.0.17", "@wagmi/core": "catalog:", + "better-sqlite3": "^12.6.2", "drizzle-kit": "^0.31.8", "embedded-postgres": "^18.1.0-beta.15", "eslint": "^9.39.2", diff --git a/server/script/openapi.ts b/server/script/openapi.ts index 124c51240..ef9d152b3 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -1,12 +1,12 @@ import { generateSpecs } from "hono-openapi"; import { writeFile } from "node:fs/promises"; -import { padHex } from "viem"; +import { padHex, zeroHash } from "viem"; import { version } from "../package.json"; process.env.ALCHEMY_ACTIVITY_ID = "activity"; process.env.ALCHEMY_WEBHOOKS_KEY = "webhooks"; -process.env.AUTH_SECRET = "auth"; +process.env.AUTH_SECRET = zeroHash; process.env.BRIDGE_API_KEY = "bridge"; process.env.BRIDGE_API_URL = "https://bridge.test"; process.env.EXPO_PUBLIC_ALCHEMY_API_KEY = " "; @@ -21,6 +21,8 @@ process.env.PANDA_API_URL = "https://panda.test"; process.env.PAX_API_KEY = "pax"; process.env.PAX_API_URL = "https://pax.test"; process.env.PAX_ASSOCIATE_ID_KEY = "pax"; +process.env.KYC_API_KEY = "panda"; +process.env.KYC_API_URL = "https://panda.test"; process.env.PERSONA_API_KEY = "persona"; process.env.PERSONA_URL = "https://persona.test"; process.env.PERSONA_WEBHOOK_SECRET = "persona"; @@ -47,6 +49,7 @@ import("../api") in: "cookie", name: "credential_id", }, + siweAuth: { type: "apiKey", in: "cookie", name: "__Secure-better-auth.session_token" }, }, }, }, diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index 6c46b4dc2..8ab87c4ae 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -69,7 +69,10 @@ describe.concurrent("authenticated", () => { let maturity: string; beforeAll(async () => { - await database.insert(cards).values([{ id: "activity", credentialId: "bob", lastFour: "1234" }]); + await database.insert(cards).values([ + { id: "first-activity-card", credentialId: "bob", lastFour: "1234" }, + { id: "second-activity-card", credentialId: "bob", lastFour: "6789" }, + ]); const borrows = await anvilClient.getContractEvents({ abi: marketAbi, eventName: "BorrowAtMaturity", @@ -148,7 +151,7 @@ describe.concurrent("authenticated", () => { }; return { id: String(index), - cardId: "activity", + cardId: index === 0 ? "first-activity-card" : "second-activity-card", hashes, payload, hash, @@ -218,7 +221,7 @@ describe.concurrent("authenticated", () => { it("reports bad transaction", async () => { await database .insert(transactions) - .values([{ id: "bad-transaction", cardId: "activity", hashes: ["0x1"], payload: {} }]); + .values([{ id: "bad-transaction", cardId: "first-activity-card", hashes: ["0x1"], payload: {} }]); const response = await appClient.index.$get( { query: { include: "card" } }, { headers: { "test-credential-id": "bob" } }, @@ -251,6 +254,17 @@ describe.concurrent("authenticated", () => { expect(json.every((item) => !item.borrow || item.borrow.maturity === Number(maturity))).toBe(true); }); + it("returns empty card activity for unmatched maturity", async () => { + expect.hasAssertions(); + const response = await appClient.index.$get( + { query: { include: "card", maturity: "0" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual([]); + }); + it("returns statement pdf", async () => { expect.hasAssertions(); const response = await appClient.index.$get( diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 6e66eeeba..dd2b8ee46 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -10,18 +10,18 @@ import { testClient } from "hono/testing"; import { decodeJwt } from "jose"; import assert from "node:assert"; import { parse, type InferOutput } from "valibot"; -import { zeroAddress } from "viem"; +import { getAddress, padHex, zeroAddress } from "viem"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import * as derive from "@exactly/common/deriveAddress"; -import chain from "@exactly/common/generated/chain"; +import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import app, { type Authentication } from "../../api/auth/authentication"; import registrationApp from "../../api/auth/registration"; import database, { credentials } from "../../database"; import * as publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; +import validFactories from "../../utils/validFactories"; import type * as SimpleWebAuthn from "@simplewebauthn/server"; import type * as SimpleWebAuthnHelpers from "@simplewebauthn/server/helpers"; @@ -57,6 +57,7 @@ describe("authentication", () => { clientExtensionResults: {}, type: "public-key", }, + query: {}, }, { headers: { cookie: "session_id=test-session" } }, ); @@ -300,7 +301,7 @@ describe("authentication", () => { vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); const id = "0x1234567890123456789012345678901234567890"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session", "Client-Fid": "12345" } }, ); @@ -326,7 +327,7 @@ describe("authentication", () => { const id = "0xaBcDef1234567890123456789012345678901234"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session" } }, ); @@ -352,10 +353,11 @@ describe("authentication", () => { const id = "0xaBcDef1234567890123456789012345678901234"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session" } }, ); expect(response.status).toBe(400); + expect(await response.json()).toEqual(expect.objectContaining({ code: "bad authentication" })); expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); }); @@ -378,6 +380,87 @@ describe("authentication", () => { expect(secondResponse.status).toBe(400); expect(await secondResponse.json()).toEqual(expect.objectContaining({ code: "no authentication" })); }); + + it("creates a credential with factory using siwe", async () => { + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const factory = [...validFactories].find((f) => f !== exaAccountFactoryAddress); + assert.ok(factory); + const id = "0xFace000000000000000000000000000000000001"; + const response = await appClient.index.$post( + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: { factory } }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, id), + columns: { factory: true }, + }); + expect(credential?.factory).toBe(factory); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("returns 400 for invalid factory using siwe", async () => { + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const id = "0xFace000000000000000000000000000000000002"; + const response = await appClient.index.$post( + { + json: { method: "siwe", id, signature: "0xdeadbeef" }, + query: { factory: getAddress(padHex("0xdead", { size: 20 })) }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual(expect.objectContaining({ code: "bad factory" })); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("authenticates existing credential with matching factory", async () => { + const factory = parse(Address, inject("ExaAccountFactory")); + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + query: { factory }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as InferOutput; + expect(json.factory).toBe(factory); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("returns 400 if factory mismatches existing credential", async () => { + const factory = [...validFactories].find((f) => f !== parse(Address, inject("ExaAccountFactory"))); + assert.ok(factory); + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + query: { factory }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); }); describe("registration", () => { @@ -482,6 +565,7 @@ describe("registration", () => { expect(secondResponse.status).toBe(400); expect(await secondResponse.json()).toEqual(expect.objectContaining({ code: "no registration" })); }); + it("creates a credential using siwe", async () => { vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); const id = "0x1234567890123456789012345678901234567895"; @@ -615,13 +699,15 @@ vi.mock("@simplewebauthn/server", async (importOriginal) => { }; }); -vi.mock("../../utils/redis", () => ({ - default: { - getdel: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), - set: vi.fn<() => Promise>().mockResolvedValue(true), - }, +const redis = vi.hoisted(() => ({ + get: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), + getdel: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), + set: vi.fn<() => Promise>().mockResolvedValue(true), + del: vi.fn<() => Promise>().mockResolvedValue(1), })); +vi.mock("../../utils/redis", () => ({ default: redis, requestRedis: redis })); + vi.mock("@simplewebauthn/server/helpers", async (importOriginal) => { const original = await importOriginal(); return { diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 04ea8809b..f2f60b0aa 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -53,18 +53,51 @@ describe("authenticated", () => { pandaId: "404", }, { - id: "frozen", + id: "debit", publicKey, account: padHex("0x4", { size: 20 }), factory: inject("ExaAccountFactory"), + pandaId: "debit", + }, + { + id: "cancel", + publicKey, + account: padHex("0x5", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "cancel", + }, + { + id: "migrate-card-upgraded-plugin", + publicKey, + account: padHex("0x6", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "migrate", + }, + { + id: "migrate-card-non-upgraded-plugin", + publicKey, + account: padHex("0x7", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "migrate", + }, + { + id: "frozen", + publicKey, + account: padHex("0x8", { size: 20 }), + factory: inject("ExaAccountFactory"), pandaId: "frozen", }, ]); await database.insert(cards).values([ - { id: "default", credentialId: "default", lastFour: "1234" }, - { id: "sig", credentialId: "sig", lastFour: "1234", productId: SIGNATURE_PRODUCT_ID }, - { id: "404", credentialId: "404", lastFour: "1234", status: "DELETED" }, - { id: "frozen", credentialId: "frozen", lastFour: "5678", status: "FROZEN" }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc6", credentialId: "default", lastFour: "1234" }, + { + id: "543c1771-beae-4f26-b662-44ea48b40dc7", + credentialId: "sig", + lastFour: "1234", + productId: SIGNATURE_PRODUCT_ID, + }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc8", credentialId: "404", lastFour: "1234", status: "DELETED" }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc9", credentialId: "frozen", lastFour: "5678", status: "FROZEN" }, ]); await Promise.all([ @@ -91,6 +124,7 @@ describe("authenticated", () => { afterEach(() => vi.resetAllMocks()); it("returns 404 card not found", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "404" } }, @@ -111,14 +145,13 @@ describe("authenticated", () => { }); it("returns panda card as default platinum product", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate }); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "default" } }, @@ -129,6 +162,7 @@ describe("authenticated", () => { expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, + cardId: "543c1771-beae-4f26-b662-44ea48b40dc6", displayName: "First Last", expirationMonth: "9", expirationYear: "2029", @@ -160,6 +194,7 @@ describe("authenticated", () => { expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, + cardId: "543c1771-beae-4f26-b662-44ea48b40dc7", displayName: "First Last", expirationMonth: "9", expirationYear: "2029", @@ -186,11 +221,6 @@ describe("authenticated", () => { factory: inject("ExaAccountFactory"), }, ]); - await database.insert(cards).values([{ id: `card-${foo}`, credentialId: foo, lastFour: "4567" }]); - - vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -198,6 +228,7 @@ describe("authenticated", () => { ); expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); }); it("returns 403 when panda user is not found", async () => { @@ -340,6 +371,7 @@ describe("authenticated", () => { }); it("returns 403 when panda user exists but is not approved", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "denied" }); const credentialId = "not-approved"; await database.insert(credentials).values({ id: credentialId, @@ -349,104 +381,13 @@ describe("authenticated", () => { pandaId: credentialId, }); - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + await expect(response.json()).resolves.toStrictEqual({ code: "kyc not approved" }); expect(captureException).not.toHaveBeenCalled(); }); - it("returns 403 when createCard fails with plain-text not approved", async () => { - const credentialId = "not-approved-plain"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4043", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => Promise.resolve("user exists but is not approved"), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).not.toHaveBeenCalled(); - }); - - it("returns 403 when createCard fails with panda user not found", async () => { - const credentialId = "panda-user-not-found"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4042", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve('{"message":"User not found","error":"NotFoundError","statusCode":404}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("returns 403 when createCard fails with panda user not found and empty body", async () => { - const credentialId = "panda-user-not-found-empty"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4044", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve(""), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("captures forbidden no-user on createCard when credential has card history", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": "404" } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -490,32 +431,41 @@ describe("authenticated", () => { }); it("creates a panda debit card with signature product id", async () => { - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCard" }); + const id = "123e4567-e89b-12d3-a456-426655440000"; + + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id }); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, id }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); - const response = await appClient.index.$post({ header: { "test-credential-id": "sig" } }); + const response = await appClient.index.$post({ header: { "test-credential-id": "debit" } }); const json = await response.json(); expect(response.status).toBe(200); const created = await database.query.cards.findFirst({ columns: { mode: true }, - where: eq(cards.credentialId, "sig"), + where: eq(cards.credentialId, "debit"), }); expect(created?.mode).toBe(0); expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "7394", + cardId: id, productId: SIGNATURE_PRODUCT_ID, }); }); it("creates a panda credit card with signature product id", async () => { - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "123e4567-e89b-12d3-a456-426655440001", + last4: "1224", + }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); const json = await response.json(); - expect(response.status).toBe(200); const created = await database.query.cards.findFirst({ @@ -525,7 +475,12 @@ describe("authenticated", () => { expect(created?.mode).toBe(1); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "1224", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ + status: "ACTIVE", + lastFour: "1224", + cardId: "123e4567-e89b-12d3-a456-426655440001", + productId: SIGNATURE_PRODUCT_ID, + }); }); it("adds user to pax when signature card is issued (upgrade from platinum)", async () => { @@ -602,10 +557,14 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "123e4567-e89b-12d3-a456-426655440016", + last4: "5555", + }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); @@ -629,6 +588,7 @@ describe("authenticated", () => { it("does not add user to pax for new signature card (no upgrade)", async () => { const testCredentialId = "new-user-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440017"; await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -637,24 +597,22 @@ describe("authenticated", () => { pandaId: "new-user-panda", }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ - ...cardTemplate, - id: "new-user-card", - last4: "8888", - }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "8888" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); expect(response.status).toBe(200); const json = await response.json(); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "8888", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "8888", cardId, productId: SIGNATURE_PRODUCT_ID }); expect(pax.addCapita).not.toHaveBeenCalled(); }); it("handles pax api error during signature card creation", async () => { const testCredentialId = "pax-error-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440018"; await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -720,20 +678,22 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "6666" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); expect(response.status).toBe(200); const json = await response.json(); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "6666", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "6666", cardId, productId: SIGNATURE_PRODUCT_ID }); }); it("handles missing persona account during signature card creation", async () => { const testCredentialId = "no-account-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440019"; + await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -750,8 +710,9 @@ describe("authenticated", () => { productId: PLATINUM_PRODUCT_ID, }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "7777" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); @@ -761,41 +722,64 @@ describe("authenticated", () => { }); it("cancels a card", async () => { - const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const }; + const id = "123e4567-e89b-12d3-a456-426655440009"; + const cardResponse = { ...cardTemplate, id, last4: "1224", status: "active" as const }; vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse); vi.spyOn(panda, "updateCard").mockResolvedValueOnce({ ...cardResponse, status: "canceled" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); - const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); + const response = await appClient.index.$post({ header: { "test-credential-id": "cancel" } }); const cancelResponse = await appClient.index.$patch({ // @ts-expect-error - bad hono patch type - header: { "test-credential-id": "eth" }, + header: { "test-credential-id": "cancel" }, json: { status: "DELETED" }, }); expect(response.status).toBe(200); expect(cancelResponse.status).toBe(200); - const card = await database.query.cards.findFirst({ - columns: { status: true }, - where: eq(cards.credentialId, "eth"), - }); + const card = await database.query.cards.findFirst({ columns: { status: true }, where: eq(cards.id, id) }); expect(card?.status).toBe("DELETED"); }); + it("sets an invalid card pin", async () => { + vi.spyOn(panda, "setPIN").mockRejectedValueOnce( + new Error( + `400 {"message":"Weak PIN. Avoid repeating (1111) or sequential (1234) numbers.","error":"BadRequestError","statusCode":400}`, + ), + ); + + const cancelResponse = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": "default" }, + json: { sessionId: "sessionId", data: "data", iv: "iv" }, + }); + + expect(cancelResponse.status).toBe(400); + await expect(cancelResponse.json()).resolves.toStrictEqual({ code: "weak pin" }); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { - await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); + const cardId = "cm-not-uuid"; + const migratedCardId = "123e4567-e89b-12d3-a456-426655440003"; + await database + .insert(cards) + .values([{ id: cardId, credentialId: "migrate-card-upgraded-plugin", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:cm" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: migratedCardId }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$post({ header: { "test-credential-id": "default" } }); + const response = await appClient.index.$post({ + header: { "test-credential-id": "migrate-card-upgraded-plugin" }, + }); - const created = await database.query.cards.findFirst({ where: eq(cards.id, "migration:cm") }); - const deleted = await database.query.cards.findFirst({ where: eq(cards.id, "cm") }); + const created = await database.query.cards.findFirst({ where: eq(cards.id, migratedCardId) }); + const deleted = await database.query.cards.findFirst({ where: eq(cards.id, cardId) }); expect(response.status).toBe(200); expect(created?.status).toBe("ACTIVE"); @@ -803,14 +787,19 @@ describe("authenticated", () => { }); it("creates a panda card having a cm card with invalid uuid", async () => { - await database.insert(cards).values([{ id: "not-uuid", credentialId: "default", lastFour: "1234" }]); + const migratedCardId = "123e4567-e89b-12d3-a456-426655440005"; + const credentialId = "migrate-card-non-upgraded-plugin"; + await database.insert(cards).values([{ id: "not-uuid", credentialId, lastFour: "1234" }]); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:not-uuid" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: migratedCardId }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$post({ header: { "test-credential-id": "default" } }); + const response = await appClient.index.$post({ + header: { "test-credential-id": credentialId }, + }); - const created = await database.query.cards.findFirst({ where: eq(cards.id, "migration:not-uuid") }); + const created = await database.query.cards.findFirst({ where: eq(cards.id, migratedCardId) }); const deleted = await database.query.cards.findFirst({ where: eq(cards.id, "not-uuid") }); expect(response.status).toBe(200); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 731cbe6b8..b02b4cecb 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -3,16 +3,27 @@ import "../mocks/deployments"; import "../mocks/sentry"; import { captureException } from "@sentry/node"; +import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; +import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; + +import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; -import database, { credentials } from "../../database"; +import database, { credentials, organizations, sources } from "../../database"; +import auth from "../../utils/auth"; +import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import type * as v from "valibot"; + const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); @@ -987,6 +998,526 @@ describe("authenticated", () => { }); }); }); + + describe("application", () => { + describe("with organization", () => { + const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); + const ownerHeaders: Headers = new Headers(); + const outsider = mnemonicToAccount("test test test test test test test test test test test bob"); + const outsiderHeaders: Headers = new Headers(); + const account = "bob"; + + const applicationPayload = { + email: "test@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "12345678", + birthDate: "1990-01-01", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main St", + city: "New York", + region: "NY", + country: "US", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "127.0.0.1", + occupation: "Engineer", + annualSalary: "100000", + accountPurpose: "Personal", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true as const, + }; + + let organizationId: string; + + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const ownerLogin = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + ownerHeaders.set("cookie", ownerLogin.headers.get("set-cookie") ?? ""); + + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { + name: "Organization", + slug: "organization", + keepCurrentActiveOrganization: false, + }, + }); + organizationId = externalOrganization?.id ?? ""; + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); + + await auth.api + .getSiweNonce({ + body: { walletAddress: outsider.address, chainId: chain.id }, + }) + .then((result) => { + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: result.nonce, + uri: `https://localhost`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + return outsider.signMessage({ message }).then((signature) => { + return auth.api + .verifySiweMessage({ + body: { message, signature, walletAddress: outsider.address, chainId: chain.id }, + request: new Request("https://localhost"), + asResponse: true, + }) + .then((response) => { + outsiderHeaders.set("cookie", response.headers.get("set-cookie") ?? ""); + }); + }); + }); + }); + + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); + }); + + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + }); + + describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: organizationId, + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); + }); + + it("returns ok when payload is valid and kyc is not started", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); + }); + + it("returns 409 when kyc is already started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + const submitApplication = vi.spyOn(panda, "submitApplication"); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as v.InferOutput }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + + it("returns 400 if terms of service are not accepted", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + }); + + describe("with encrypted payload", () => { + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { key, iv, ciphertext, tag }; + } + + it("returns ok when payload is valid", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); + }); + + it("returns 403 no organization", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await outsider.signMessage({ message }), + walletAddress: outsider.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + expect(response.status).toBe(403); + }); + }); + }); + + describe("update", () => { + it("returns ok when kyc is started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + }); + + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + }); + }); + }); }); const basicAccount = { diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts new file mode 100644 index 000000000..dd72e5cf9 --- /dev/null +++ b/server/test/api/webhook.test.ts @@ -0,0 +1,209 @@ +import "../mocks/sentry"; + +import { eq } from "drizzle-orm"; +import { testClient } from "hono/testing"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import chain from "@exactly/common/generated/chain"; + +import app from "../../api/webhook"; +import database, { sources } from "../../database"; +import auth from "../../utils/auth"; + +const appClient = testClient(app); + +const owner = mnemonicToAccount("test test test test test test test test test test test junk"); +const integratorAccount = mnemonicToAccount("test test test test test test test test test test test integrator"); + +describe("webhook", () => { + const integratorHeaders = new Headers(); + + describe("authenticated", () => { + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + const ownerHeaders = new Headers(); + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + + const integratorNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: integratorAccount.address, chainId: chain.id }, + }); + const integratorMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: integratorNonceResult.nonce, + uri: `https://localhost`, + address: integratorAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const integratorResponse = await auth.api.verifySiweMessage({ + body: { + message: integratorMessage, + signature: await integratorAccount.signMessage({ message: integratorMessage }), + walletAddress: integratorAccount.address, + chainId: chain.id, + email: "integrator@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + integratorHeaders.set("cookie", `${integratorResponse.headers.get("set-cookie")}`); + const integrator = await auth.api.getSession({ headers: integratorHeaders }); + if (!integrator) throw new Error("integrator not found"); + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { name: "External Organization", slug: "external-organization" }, + }); + + const integratorInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: integrator.user.email, role: "admin", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: integratorHeaders, body: { invitationId: integratorInvitation.id } }); + }); + + afterEach(async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + await database.delete(sources).where(eq(sources.id, id)); + }); + + it("creates and gets a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const cookie = integratorHeaders.get("cookie") ?? ""; + + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + const getWebhook = await appClient.index.$get({}, { headers: { cookie } }); + + expect(getWebhook.status).toBe(200); + expect(response.status).toBe(200); + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { url: "https://test.com", secret: expect.any(String) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }, + }); + + await expect(getWebhook.json()).resolves.toStrictEqual({ + test: { + url: "https://test.com", + }, + }); + }); + + it("updates a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const update = await appClient.index.$post( + { + json: { + name: "test", + url: "https://test.updated.com", + transaction: { created: "https://test.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const createAnother = await appClient.index.$post( + { + json: { + name: "another", + url: "https://another.updated.com", + transaction: { created: "https://another.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { + url: "https://test.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://test.updated.com/created" }, + }, + another: { + url: "https://another.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://another.updated.com/created" }, + }, + }, + }); + + expect(create.status).toBe(200); + expect(update.status).toBe(200); + expect(createAnother.status).toBe(200); + }); + + it("deletes a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const remove = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ type: "uphold", webhooks: {} }); + expect(create.status).toBe(200); + expect(remove.status).toBe(200); + }); + + it("returns 200 when webhook is not found", async () => { + const getWebhook = await appClient.index.$get({}, { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }); + expect(getWebhook.status).toBe(200); + await expect(getWebhook.json()).resolves.toStrictEqual({}); + }); + }); +}); diff --git a/server/test/database.ts b/server/test/database.ts index 6890ae9a9..6d4476e2a 100644 --- a/server/test/database.ts +++ b/server/test/database.ts @@ -76,6 +76,44 @@ export default async function setup() { let substreamsExited: Promise = Promise.resolve(); let substreamsOutputFlushed: Promise = Promise.resolve(); try { + const warmupController = new AbortController(); + const warmupLog = `${startupLogs}/firehose-warmup.log`; + const warmupOutput = createWriteStream(warmupLog); + const warmup = $({ + cancelSignal: warmupController.signal, + forceKillAfterDelay: 33_333, + env: { ETH_RPC_SHORT_BLOCK_NUMBER_NOTATION: "true" }, + })`fireeth start reader-node,merger --advertise-chain-name=anvil --config-file= --data-dir=node_modules/@exactly/.firehose --reader-node-path=bash --reader-node-arguments=${'-c "\ + fireeth tools poll-rpc-blocks http://localhost:8545 0 | tsx script/firehose.ts"'}`; + const warmupLogWatcher = watchProcessOutput(warmup, warmupOutput, warmupController); + try { + await Promise.race([ + waitOn({ + resources: ["node_modules/@exactly/.firehose/storage/merged-blocks/0000000000.dbin.zst"], // cspell:ignore dbin + timeout: 120_000, + }), + postgresExited.then(() => { + throw new Error("postgres exited waiting fireeth warmup"); + }), + warmupLogWatcher.exit.then(() => { + throw new Error("warmup exited before merged blocks"); + }), + warmupLogWatcher.outputError, + ]); + } catch (error) { + warmupController.abort(); + await warmupLogWatcher.exit.catch(() => undefined); + await warmupLogWatcher.outputFlushed; + const warmupText = await readFile(warmupLog, "utf8").catch(() => ""); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`wait firehose warmup: ${message}\nfirehose:\n${warmupText}`, { cause: error }); + } finally { + warmupLogWatcher.stopWatchingOutput(); + warmupController.abort(); + await warmupLogWatcher.exit.catch(() => undefined); + await warmupLogWatcher.outputFlushed; + } + const firehoseLog = `${startupLogs}/firehose.log`; const firehoseOutput = createWriteStream(firehoseLog); const firehose = $({ diff --git a/server/test/e2e.ts b/server/test/e2e.ts index c5c73ce05..b5df2e44e 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -55,7 +55,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => ...original, autoCredit: vi.fn().mockResolvedValue(false), createCard: vi.fn().mockImplementation((userId: string) => { - const id = `crd_${Math.random().toString(36).slice(2)}`; + const id = crypto.randomUUID(); const card: Card = { expirationMonth: "12", expirationYear: "2030", @@ -109,6 +109,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }); }), getUser: vi.fn().mockImplementation((userId: string) => Promise.resolve(users.get(userId))), + getApplicationStatus: vi.fn().mockResolvedValue({ id: "pandaId", applicationStatus: "approved" }), isPanda: vi.fn().mockResolvedValue(true), setPIN: vi.fn().mockResolvedValue({}), signIssuerOp: vi.fn().mockResolvedValue("0x" + "ab".repeat(65)), @@ -179,6 +180,10 @@ vi.mock("../utils/persona", async (importOriginal: () => Promise }; }); +vi.mock("../utils/allower", () => ({ + default: vi.fn(() => Promise.resolve({ allow: vi.fn().mockResolvedValue({}) })), +})); + vi.mock("@sentry/node", async (importOriginal) => { const { captureException, ...original } = await importOriginal(); return { diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index ffe1b1a28..7afe05809 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -55,39 +55,6 @@ describe("address activity", () => { ]); }); - it("captures no balance once after retries", async () => { - vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); - vi.spyOn(keeper, "exaSend").mockImplementation((spanOptions) => - Promise.resolve( - spanOptions.op === "exa.poke" ? null : ({ status: "success" } as Awaited>), - ), - ); - - const response = await appClient.index.$post({ - ...activityPayload, - json: { - ...activityPayload.json, - event: { - ...activityPayload.json.event, - activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }], - }, - }, - }); - - await vi.waitUntil( - () => vi.mocked(captureException).mock.calls.some(([error, hint]) => isNoBalance(error, hint, "warning")), - 26_666, - ); - - expect( - vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "warning")), - ).toHaveLength(1); - expect( - vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "error")), - ).toHaveLength(0); - expect(response.status).toBe(200); - }); - it("fails with unexpected error", async () => { const getCode = vi.spyOn(publicClient, "getCode"); getCode.mockRejectedValue(new Error("Unexpected")); @@ -139,7 +106,7 @@ describe("address activity", () => { expect(captureException).toHaveBeenCalledWith( new WaitForTransactionReceiptTimeoutError({ hash: zeroHash }), - expect.objectContaining({ level: "error", fingerprint: ["{{ default }}", "unknown"] }), + expect.anything(), ); expect( vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "warning")), @@ -303,6 +270,7 @@ describe("address activity", () => { }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -334,6 +302,7 @@ describe("address activity", () => { cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH", message: "custom reason" }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -365,6 +334,7 @@ describe("address activity", () => { cause: new ContractFunctionRevertedError({ abi: [], data: "0xdeadbeef", functionName: "pokeETH" }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -394,6 +364,7 @@ describe("address activity", () => { vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce( new BaseError("test", { cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH" }) }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -421,6 +392,7 @@ describe("address activity", () => { it("fingerprints shouldRetry as unknown", async () => { vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce(new Error("unexpected")); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -492,7 +464,7 @@ describe("address activity", () => { }); it("pokes eth with value when rawValue is 0x", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -512,22 +484,14 @@ describe("address activity", () => { waitForWETHMarket(account, deposit), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "pokeETH", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(deposit); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); }); it("pokes eth without value", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -555,15 +519,7 @@ describe("address activity", () => { waitForWETHMarket(account, deposit), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "pokeETH", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(deposit); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); @@ -606,7 +562,7 @@ describe("address activity", () => { }); it("pokes token without value", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const weth = parseEther("2"); await keeper.exaSend( { name: "mint", op: "tx.mint" }, @@ -637,23 +593,15 @@ describe("address activity", () => { waitForWETHMarket(account, weth), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "poke", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked, options]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(weth); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); - }); + }, 0); it("ignores token without value and zero rawValue", async () => { vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); const token = activityPayload.json.event.activity[1]; @@ -676,17 +624,9 @@ describe("address activity", () => { }, }, }); - await vi.waitUntil(() => exaSend.mock.calls.length > 0, 333).catch(() => undefined); + await vi.waitUntil(() => poke.mock.calls.length > 0, 333).catch(() => undefined); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "poke", - ), - ).toBe(false); + expect(poke.mock.calls.some(([poked, options]) => poked === account)).toBe(false); expect(sendPushNotification).not.toHaveBeenCalled(); expect(response.status).toBe(200); }); @@ -790,6 +730,27 @@ describe("address activity", () => { expect(sendPushNotification).not.toHaveBeenCalled(); expect(response.status).toBe(200); }); + + it("calls poke with correct ignore option", async () => { + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const response = await appClient.index.$post({ + ...activityPayload, + json: { + ...activityPayload.json, + event: { + ...activityPayload.json.event, + activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }], + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledWith(account, { ignore: [`NotAllowed(${account})`] }); + }); }); async function getWETHMarket(account: Address) { @@ -817,7 +778,7 @@ async function waitForWETHMarket(account: Address, floatingDepositAssets: bigint return false; throw error; } - }, 26_666); + }, 26_666_000); } function isNoBalance(error: unknown, hint: unknown, level: "error" | "warning") { diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 145a1c43d..f9a4509d0 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,8 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { parse } from "valibot"; +import { createHmac, randomBytes } from "node:crypto"; +import { object, parse, string } from "valibot"; import { BaseError, createWalletClient, @@ -38,14 +39,13 @@ import chain, { exaPluginAbi, issuerCheckerAbi, marketAbi, - marketUSDCAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import ProposalType from "@exactly/common/ProposalType"; import { Address, type Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; -import database, { cards, credentials, transactions } from "../../database"; +import database, { cards, credentials, sources, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; @@ -1582,7 +1582,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("over capture debit", async () => { + it("over-captures debit", async () => { const hold = 25; const capture = 30; @@ -1634,7 +1634,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("partial capture debit", async () => { + it("partial-captures debit", async () => { const hold = 80; const capture = 40; const cardId = "partial-capture-debit"; @@ -1686,7 +1686,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture debit", async () => { + it("force-captures debit", async () => { const capture = 42; const cardId = "force-capture-debit"; @@ -1722,7 +1722,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture fraud", async () => { + it("force-captures fraud", async () => { const updateUser = vi.spyOn(panda, "updateUser").mockResolvedValue(userResponseTemplate); const currentFunds = await publicClient .readContract({ @@ -1901,7 +1901,12 @@ describe("concurrency", () => { Promise.all([ keeper.exaSend( { name: "mint", op: "tx.mint" }, - { address: inject("USDC"), abi: mockERC20Abi, functionName: "mint", args: [account2, 70_000_000n] }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [account2, 70_000_000n], + }, ), keeper.exaSend( { name: "create account", op: "exa.account" }, @@ -1912,12 +1917,25 @@ describe("concurrency", () => { args: [0n, [{ x: hexToBigInt(owner2.account.address), y: 0n }]], }, ), - ]).then(() => - keeper.exaSend( - { name: "poke", op: "exa.poke" }, - { address: account2, abi: exaPluginAbi, functionName: "poke", args: [marketUSDCAddress] }, - ), - ), + ]) + .then(() => + keeper.writeContract({ + address: account2, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), ]); }); @@ -2008,7 +2026,7 @@ describe("concurrency", () => { afterEach(() => vi.useRealTimers()); - it("mutex timeout", async () => { + it("times out when mutex is locked", async () => { const getMutex = vi.spyOn(panda, "getMutex"); const cardId = `${account2}-card`; const promises = Promise.all([ @@ -2061,6 +2079,453 @@ describe("concurrency", () => { }); }); +describe("webhooks", () => { + let webhookOwner: WalletClient, typeof chain, ReturnType>; + let webhookAccount: Address; + const secret = randomBytes(16).toString("hex"); + + beforeAll(async () => { + webhookOwner = createWalletClient({ + chain, + transport: http(), + account: privateKeyToAccount(generatePrivateKey()), + }); + webhookAccount = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(webhookOwner.account.address), + y: zeroHash, + }); + await Promise.all([ + database.insert(sources).values([ + { + id: "test", + config: { + type: "uphold", + webhooks: { sandbox: { url: "https://exa.test", secret } }, + }, + }, + ]), + database + .insert(credentials) + .values([ + { + id: webhookAccount, + publicKey: new Uint8Array(), + account: webhookAccount, + factory: zeroAddress, + source: "test", + pandaId: webhookAccount, + }, + ]) + .then(() => { + return database + .insert(cards) + .values([{ id: `${webhookAccount}-card`, credentialId: webhookAccount, lastFour: "1234", mode: 0 }]); + }), + + anvilClient.setBalance({ address: webhookOwner.account.address, value: 10n ** 24n }), + Promise.all([ + keeper.exaSend( + { name: "mint", op: "tx.mint" }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [webhookAccount, 50_000_000n], + }, + ), + keeper.exaSend( + { name: "create account", op: "exa.account" }, + { + address: inject("ExaAccountFactory"), + abi: exaAccountFactoryAbi, + functionName: "createAccount", + args: [0n, [{ x: hexToBigInt(webhookOwner.account.address), y: 0n }]], + }, + ), + ]) + .then(() => + keeper.writeContract({ + address: webhookAccount, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), + ]); + }); + + afterEach(() => vi.resetAllMocks()); + + it("forwards transaction created with exchangeRate", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: cardId, + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 100, + localAmount: 85, + localCurrency: "eur", + exchangeRate: 1.176_470_588_2, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.176_470_588_2 } }, + }); + }); + + it("forwards transaction created without exchangeRate when same currency", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "same-currency-tx", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); + }); + + it("forwards transaction updated without exchangeRate", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionUpdated, + json: { + ...transactionUpdated.json, + body: { + ...transactionUpdated.json.body, + id: "forward-transaction-updated", + spend: { + ...transactionUpdated.json.body.spend, + cardId, + userId: webhookAccount, + localCurrency: "eur", + localAmount: 6800, + authorizedAt: new Date().toISOString(), + status: "pending", + authorizationUpdateAmount: 98, + }, + }, + }, + }); + + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); + }); + + it("forwards transaction completed with exchangeRate", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const fetch = globalThis.fetch; + let publishCounter = 0; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publishCounter++; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + + await appClient.index.$post({ + ...transactionCompleted, + json: { + ...transactionCompleted.json, + body: { + ...transactionCompleted.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCompleted.json.body.spend, + cardId, + userId: webhookAccount, + postedAt: new Date().toISOString(), + status: "completed", + amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, + authorizedAmount: 99, + }, + }, + }, + }); + + await vi.waitUntil(() => publishCounter > 1, 60_000); + const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.178_571_428_6 } }, + }); + }); + + it("forwards declined transaction webhook", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: "declined-webhook-tx", + spend: { + ...authorization.json.body.spend, + cardId, + userId: webhookAccount, + status: "declined", + declinedReason: "webhook declined", + }, + }, + }, + }); + + expect(response.status).toBe(200); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + resource: "transaction", + action: "created", + body: { spend: { status: "declined", declinedReason: "webhook declined" } }, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("forwards card updated active", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards card updated canceled", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...cardCanceled, + json: { + ...cardCanceled.json, + body: { + ...cardCanceled.json.body, + userId: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards user updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...userUpdated, + json: { + ...userUpdated.json, + body: { + ...userUpdated.json.body, + id: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("logs text on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith("%j", expect.objectContaining({ response: "OK" })); + }); + + it("logs json on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ status: 200, message: "OK" })), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith( + "%j", + expect.objectContaining({ response: { status: 200, message: "OK" } }), + ); + }); +}); + const authorization = { header: { signature: "panda-signature" }, json: { @@ -2083,6 +2548,7 @@ const authorization = { merchantCity: "buenos aires", merchantCountry: "AR", merchantName: "99999", + merchantId: "550e8400-e29b-41d4-a716-446655440000", status: "pending", userEmail: "mail@mail.com", userFirstName: "David", @@ -2093,6 +2559,189 @@ const authorization = { }, } as const; +const cardUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + tokenWallets: ["Apple"], + }, + }, +} as const; + +const cardCanceled = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "canceled", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + }, + }, +} as const; + +const userUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + resource: "user", + action: "updated", + body: { + id: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", + firstName: "David", + lastName: "Mayer", + email: "mail@mail.com", + isActive: true, + isTermsOfServiceAccepted: true, + applicationStatus: "pending", + applicationExternalVerificationLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationCompletionLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationReason: "COMPROMISED_PERSONS, PEP", + }, + }, +} as const; + +const transactionCreated = { + header: { signature: "panda-signature" }, + json: { + id: "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "pending", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "rain@gmail.com", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "5641 - Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + merchantCategoryCode: "5641", + }, + }, + action: "created", + resource: "transaction", + }, +} as const; + +const transactionUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + body: { + id: "30dcf8c6-a1e5-48f1-9c40-ecffe8253d25", + type: "spend", + spend: { + amount: 8000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "reversed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "zjdnflol@gamil.com", + merchantId: "d0a30859-096d-57f4-bffd-fd745f44e048", + localAmount: 8000, + authorizedAt: "2025-06-25T15:24:11.337Z", + merchantCity: " ", + merchantName: "Test ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "jason", + merchantCountry: " ", + authorizedAmount: 8000, + merchantCategory: " - ", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test", + merchantCategoryCode: "", + enrichedMerchantCategory: "Education", + authorizationUpdateAmount: -2000, + }, + }, + action: "updated", + resource: "transaction", + }, +} as const; + +const transactionCompleted = { + header: { signature: "panda-signature" }, + json: { + id: "77474a56-51eb-4918-b09e-73cf20077b1b", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "completed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + postedAt: "2025-07-03T19:57:04.332Z", + userEmail: "rain@gmail.com", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test Refund", + merchantCategoryCode: "5641", + enrichedMerchantCategory: "Refunds - Insufficient Funds", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + }, + }, + action: "completed", + resource: "transaction", + }, +} as const; + const receipt = { status: "success", blockHash: zeroHash, @@ -2188,6 +2837,13 @@ const userResponseTemplate = { vi.mock("@sentry/node", { spy: true }); +const webhookLogger = vi.hoisted(() => vi.fn()); + +vi.mock("debug", () => { + const createDebug = vi.fn().mockReturnValueOnce(vi.fn()).mockReturnValueOnce(webhookLogger); + return { default: createDebug }; +}); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 6c3774e02..e5e86f125 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -5,22 +5,41 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { hexToBytes, padHex, zeroHash } from "viem"; +import { hexToBytes, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; +import { wethAddress } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/persona"; +import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; +import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); +const mockAllow = vi.fn().mockResolvedValue({}); + +vi.mock("../../utils/allower", () => ({ + default: vi.fn(() => + Promise.resolve({ + allow: mockAllow, + }), + ), +})); +vi.mock("@exactly/common/generated/chain", async () => { + const actual = await vi.importActual("@exactly/common/generated/chain"); + return { + ...actual, + firewallAddress: "0x1234567890123456789012345678901234567890", + }; +}); describe("with reference", () => { const referenceId = "hook-persona"; @@ -381,7 +400,10 @@ describe("persona hook", () => { }); }); - afterEach(() => vi.resetAllMocks()); + afterEach(async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "persona-ref")); + vi.restoreAllMocks(); + }); it("creates panda and pax user on valid inquiry", async () => { vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); @@ -389,7 +411,9 @@ describe("persona hook", () => { vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); const response = await appClient.index.$post({ - header: { "persona-signature": "t=1,v1=sha256" }, + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, json: { ...validPayload, data: { @@ -428,6 +452,236 @@ describe("persona hook", () => { product: "travel insurance", }); }); + + it("pokes assets when balances are positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) + .mockResolvedValueOnce(parseEther("2")); + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + it("pokes only eth when balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([{ asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }]) + .mockResolvedValueOnce(0n); + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledTimes(1); + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + it("skips weth when eth balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) + .mockResolvedValueOnce(parseEther("5")) + .mockResolvedValueOnce(parseEther("2")); + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1")); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledTimes(1); + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + it("does not poke when balances are zero", async () => { + const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) + .mockResolvedValueOnce(0n); + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(0n); + + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitFor( + () => { + expect(exaSendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ op: "exa.poke" }), expect.anything()); + }, + { timeout: 100, interval: 20 }, + ); + }); + + it("returns error when firewall call fails", async () => { + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + + mockAllow.mockRejectedValueOnce(new Error("Firewall error")); + + const response = await appClient.index.$post({ + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, + json: { + ...validPayload, + data: { + ...validPayload.data, + attributes: { + ...validPayload.data.attributes, + payload: { + ...validPayload.data.attributes.payload, + included: [...validPayload.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ code: "firewall error" }); + }); }); describe("manteca template", () => { @@ -447,6 +701,7 @@ describe("manteca template", () => { it("handles manteca template and adds document", async () => { vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_manteca" } }); + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "should-not-be-called" }); const response = await appClient.index.$post({ header: { "persona-signature": "t=1,v1=sha256" }, diff --git a/server/test/utils/gcp.test.ts b/server/test/utils/gcp.test.ts new file mode 100644 index 000000000..188f745d6 --- /dev/null +++ b/server/test/utils/gcp.test.ts @@ -0,0 +1,41 @@ +import { access, writeFile } from "node:fs/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { initializeGcpCredentials, resetGcpInitialization } from "../../utils/gcp"; + +vi.mock("node:fs/promises", () => ({ + writeFile: vi.fn(), + access: vi.fn(), +})); + +const mockWriteFile = vi.mocked(writeFile); +const mockAccess = vi.mocked(access); + +describe("gcp credentials security", () => { + beforeEach(() => { + vi.clearAllMocks(); + // cspell:ignore unstub + vi.unstubAllEnvs(); + resetGcpInitialization(); + mockAccess.mockRejectedValue(new Error("File not found")); + }); + + it("creates credentials file with secure permissions (0o600)", async () => { + vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K"); + + await initializeGcpCredentials(); + + expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), { + mode: 0o600, + }); + }); + + it("returns early when credentials already exist", async () => { + vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K"); + mockAccess.mockResolvedValue(); + + await initializeGcpCredentials(); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/server/test/utils/keeper.test.ts b/server/test/utils/keeper.test.ts index e5e024425..4109b3d3b 100644 --- a/server/test/utils/keeper.test.ts +++ b/server/test/utils/keeper.test.ts @@ -4,7 +4,14 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { setImmediate } from "node:timers/promises"; -import { encodeErrorResult, getContractError, RawContractError } from "viem"; +import { + encodeErrorResult, + getContractError, + HttpRequestError, + InvalidInputRpcError, + RawContractError, + ResourceNotFoundRpcError, +} from "viem"; import { afterEach, describe, expect, inject, it, vi } from "vitest"; import { auditorAbi } from "@exactly/common/generated/chain"; @@ -12,9 +19,10 @@ import { auditorAbi } from "@exactly/common/generated/chain"; import keeper from "../../utils/keeper"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; +import traceClient from "../../utils/traceClient"; +import type { Hash, Hex } from "@exactly/common/validation"; import type * as timers from "node:timers/promises"; -import type { Hex } from "viem"; describe("fault tolerance", () => { it("recovers if transaction is missing", async () => { @@ -86,13 +94,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); await vi.waitUntil(() => blockedHashes.length === 1); const second = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); const sendBlocked = await Promise.allSettled([first, second]); @@ -132,7 +140,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); const mockWaitForTransactionReceipt = vi @@ -142,13 +150,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer 0", op: "test.transfer[0]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const second = keeper.exaSend( { name: "test transfer 1", op: "test.transfer[1]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const sendBlocked = await Promise.allSettled([ @@ -158,7 +166,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index + 2}`, op: `test.transfer[${index + 2}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ]); @@ -175,7 +183,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await vi.waitUntil( @@ -193,7 +201,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index}`, op: `test.transfer[${index}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ); @@ -308,6 +316,69 @@ describe("level option", () => { }); }); +describe("trace transaction retry", () => { + it("retries on ResourceNotFoundRpcError", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction + .mockRejectedValueOnce(new ResourceNotFoundRpcError(new HttpRequestError({ body: {}, url: "" }))) + .mockResolvedValueOnce({ + from: "0x", + gas: "0x0", + gasUsed: "0x0", + input: "0x", + output: "0x", + to: "0x", + type: "CALL", + }); + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + expect(traceTransaction).toHaveBeenCalledTimes(2); + }); + + it("retries on InvalidInputRpcError", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction + .mockRejectedValueOnce(new InvalidInputRpcError(new HttpRequestError({ body: {}, url: "" }))) + .mockResolvedValueOnce({ + from: "0x", + gas: "0x0", + gasUsed: "0x0", + input: "0x", + output: "0x", + to: "0x", + type: "CALL", + }); + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + expect(traceTransaction).toHaveBeenCalledTimes(2); + }); + + it("captures exception when trace fails with non-retryable error", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction.mockRejectedValue(new Error("debug_traceTransaction unavailable")); + const initialCalls = vi.mocked(captureException).mock.calls.length; + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + const calls = vi.mocked(captureException).mock.calls.slice(initialCalls); + expect(calls).toContainEqual([ + expect.objectContaining({ message: "debug_traceTransaction unavailable" }), + expect.objectContaining({ level: "error" }), + ]); + }); +}); + vi.mock("@sentry/node", { spy: true }); vi.mock("node:timers/promises", async (importOriginal) => { const original = await importOriginal(); diff --git a/server/test/utils/manteca.test.ts b/server/test/utils/manteca.test.ts index 256ae36de..b90a9e612 100644 --- a/server/test/utils/manteca.test.ts +++ b/server/test/utils/manteca.test.ts @@ -2,7 +2,7 @@ import "../mocks/sentry"; import { parse } from "valibot"; import { padHex } from "viem"; -import { baseSepolia, optimism } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Address } from "@exactly/common/validation"; @@ -264,8 +264,8 @@ describe("manteca utils", () => { await expect(manteca.withdrawBalance("456", "USDC", address)).rejects.toThrow(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); }); - it("withdraws with BASE network on development chain", async () => { - chainMock.id = baseSepolia.id; + it("withdraws with OPTIMISM network on development chain", async () => { + chainMock.id = optimismSepolia.id; const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(mockFetchResponse({ ...mockBalanceBase, balance: { USDC: "100.00" } })) @@ -276,7 +276,7 @@ describe("manteca utils", () => { const withdrawCall = fetchSpy.mock.calls[1]; const body = JSON.parse(withdrawCall?.[1]?.body as string) as Record; expect(body).toMatchObject({ - destination: { address, network: "BASE" }, + destination: { address, network: "OPTIMISM" }, }); }); }); @@ -414,7 +414,7 @@ describe("manteca utils", () => { }); it("returns currencies on development chain", async () => { - chainMock.id = baseSepolia.id; + chainMock.id = optimismSepolia.id; vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404, diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index c8e27db73..bc58ba899 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -2,7 +2,7 @@ import "../mocks/persona"; import "../mocks/sentry"; import { array, minLength, number, object, optional, pipe, safeParse, string, union } from "valibot"; -import { baseSepolia, optimism } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import * as persona from "../../utils/persona"; @@ -577,7 +577,7 @@ describe("evaluateAccount", () => { describe("getAllowedMantecaIds", () => { describe("development mode", () => { beforeEach(() => { - chainMock.id = baseSepolia.id; + chainMock.id = optimismSepolia.id; }); it("returns allowed ids for supported countries (AR)", () => { diff --git a/server/test/utils/statement.test.ts b/server/test/utils/statement.test.ts index 9fc9d7f5c..fa84ec073 100644 --- a/server/test/utils/statement.test.ts +++ b/server/test/utils/statement.test.ts @@ -1,132 +1,251 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { isValidElement, type ReactNode } from "react"; -import { describe, expect, it } from "vitest"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; import { MATURITY_INTERVAL } from "@exactly/lib"; import Statement, { format } from "../../utils/Statement"; +const directory = path.join("node_modules/@exactly/.runtime"); + describe("statement rendering", () => { + beforeAll(async () => { + await mkdir(directory, { recursive: true }); + }); it("renders with purchases", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "purchase-1", - description: "grocery store", - installments: [{ amount: 50.25, current: 1, total: 3 }], - timestamp: "2025-12-19T11:35:11.030Z", - }, - { - id: "purchase-2", - description: "gas station", - installments: [{ amount: 30.5, current: 2, total: 2 }], - timestamp: "2025-12-19T11:22:49.412Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "grocery store", + installments: [{ amount: 50.25, current: 1, total: 3 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + { + id: "purchase-2", + description: "gas station", + installments: [{ amount: 30.5, current: 2, total: 2 }], + timestamp: "2025-12-19T11:22:49.412Z", + }, + ], }, ], - lastFour: "1234", maturity: 1_768_435_200, + payments: [], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-purchases-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 1234"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); - expect(text).toContain("Purchases"); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); expect(text).toContain("grocery store"); - expect(text).toContain("Installment 1 of 3"); - expect(text).toContain("USDC 50.25"); + expect(text).toContain("$50.25"); expect(text).toContain("gas station"); - expect(text).toContain("Installment 2 of 2"); - expect(text).toContain("USDC 30.50"); + expect(text).toContain("$30.50"); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); }); - it("renders with repayments", async () => { + it("renders with payments", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "repay-1", - amount: 100, - currency: "USDC", - positionAmount: 105.82, - timestamp: "2025-12-19T11:35:11.030Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], }, ], - lastFour: "1234", maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-payments-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 1234"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); expect(text).toContain("Payments"); - expect(text).toContain("5.50% discount applied"); - expect(text).toContain("USDC 100.00"); + expect(text).toContain("$100.00"); }); it("renders with empty data", async () => { const statement = { - data: [], - lastFour: "", + account: "0x92bD...e82BA8", + cards: [], maturity: 1_768_435_200, + payments: [], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-empty-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); }); - it("renders with both purchases and repayments", async () => { + it("renders with multiple cards", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "purchase-3", - description: "online purchase", - installments: [{ amount: 75, current: 1, total: 1 }], - timestamp: "2025-12-19T11:35:11.030Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "grocery store", + installments: [{ amount: 50, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], }, { - id: "repay-2", - amount: 200, - currency: "USDC", - positionAmount: 206.6, - timestamp: "2025-12-20T10:00:00.000Z", + id: "card-2", + lastFour: "5678", + purchases: [ + { + id: "purchase-2", + description: "online purchase", + installments: [{ amount: 75, current: 1, total: 1 }], + timestamp: "2025-12-19T11:22:49.412Z", + }, + ], }, ], - lastFour: "5678", maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 25, positionAmount: 25, timestamp: "2025-12-20T10:00:00.000Z" }], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-multiple-cards-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(pdf.byteLength).toBeGreaterThan(0); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 5678"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); - expect(text).toContain("Purchases"); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain("Card **** 5678"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); + expect(text).toContain("grocery store"); + expect(text).toContain("$50.00"); expect(text).toContain("online purchase"); - expect(text).toContain("Installment 1 of 1"); - expect(text).toContain("USDC 75.00"); + expect(text).toContain("$75.00"); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); expect(text).toContain("Payments"); - expect(text).toContain("% discount applied"); - expect(text).toContain("USDC 200.00"); + expect(text).toContain("$25.00"); + }); + + it("renders discount chip for early payment", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 105.82, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 105.82, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-discount-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).toContain("5.50% discount"); + expect(text).not.toContain("penalty"); + expect(text).toContain("$0.00"); + }); + + it("renders penalty chip for late payment", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 102.31, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-penalty-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).toContain("2.31% penalty"); + expect(text).not.toContain("discount"); + expect(text).toContain("$0.00"); + }); + + it("renders no chip when amount equals positionAmount", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + await writeFile(path.join(directory, `statement-no-chip-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).not.toContain("discount"); + expect(text).not.toContain("penalty"); }); }); diff --git a/server/utils/Statement.tsx b/server/utils/Statement.tsx index ee6b1d393..d3ad911c9 100644 --- a/server/utils/Statement.tsx +++ b/server/utils/Statement.tsx @@ -1,145 +1,208 @@ -import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; +import { Document, G, Page, Path, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer"; import React from "react"; import { MATURITY_INTERVAL } from "@exactly/lib"; const Statement = ({ - data, - lastFour, + account, + cards, maturity, + payments, }: { - data: ( - | { - amount: number; - currency: string; - id: string; - positionAmount: number; - timestamp: string; - } - | { - description: string; - id: string; - installments: { amount: number; current: number; total: number }[]; - timestamp: string; - } - )[]; - lastFour: string; - maturity: number; -}) => { - const dueDate = format(maturity); - const statementDate = format(maturity - MATURITY_INTERVAL); - const repayments = data.filter( - ( - item, - ): item is { - amount: number; - currency: string; - id: string; - positionAmount: number; - timestamp: string; - } => "positionAmount" in item, - ); - const purchases = data.filter( - ( - item, - ): item is { + account: string; + cards: { + id: string; + lastFour: string; + purchases: { description: string; id: string; installments: { amount: number; current: number; total: number }[]; timestamp: string; - } => "description" in item, + }[]; + }[]; + maturity: number; + payments: { amount: number; id: string; positionAmount: number; timestamp: string }[]; +}) => { + const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + + const totalSpent = cards.reduce( + (sum, card) => + sum + + card.purchases.reduce((s, p) => s + p.installments.reduce((a, installment) => a + installment.amount, 0), 0), + 0, ); + const totalPayments = payments.reduce((sum, p) => sum + p.positionAmount, 0); + const dueBalance = totalSpent - totalPayments; return ( - Exa App - Card Statement + + + + + + + + + + + + + + + + Statement + + Account + {account} + + + Date + {format(new Date((maturity - MATURITY_INTERVAL) * 1000))} + - - - - Id: - {maturity} - - - Date: - {statementDate} - + + + Period + + {format(new Date((maturity - MATURITY_INTERVAL) * 1000))} to {format(new Date(maturity * 1000))} + - - - Card No.: - **** **** **** {lastFour} - - - Due date: - {dueDate} + + Due date + {format(new Date(maturity * 1000))} + + + Total spent + {currency.format(totalSpent)} + + + + Summary + {cards.map((card) => { + const cardTotal = card.purchases.reduce( + (sum, p) => sum + p.installments.reduce((a, installment) => a + installment.amount, 0), + 0, + ); + return ( + + Card **** {card.lastFour} purchases + + {currency.format(cardTotal)} + + ); + })} + {payments.length > 0 && ( + + Payments + + -{currency.format(totalPayments)} + )} + + Due balance + {currency.format(dueBalance)} - {repayments.length > 0 && ( - <> + {cards.map((card) => { + const cardTotal = card.purchases.reduce( + (sum, p) => sum + p.installments.reduce((a, installment) => a + installment.amount, 0), + 0, + ); + return ( + + Card **** {card.lastFour} purchases + + DATE + DESCRIPTION + INSTALLMENTS + TOTAL + + {card.purchases.map((purchase) => ( + + {format(new Date(purchase.timestamp))} + {purchase.description} + + {purchase.installments + .map((installment) => `${installment.current} of ${installment.total}`) + .join(", ")} + + + {currency.format(purchase.installments.reduce((sum, installment) => sum + installment.amount, 0))} + + + ))} + + + Total spent on card **** {card.lastFour} + + {currency.format(cardTotal)} + + + ); + })} + {payments.length > 0 && ( + Payments - {repayments.map((item) => { + + DATE + DESCRIPTION + TOTAL + + {payments.map((payment) => { const percent = - item.positionAmount === 0 ? 0 : ((item.positionAmount - item.amount) / item.positionAmount) * 100; + payment.positionAmount === 0 + ? 0 + : ((payment.positionAmount - payment.amount) / payment.positionAmount) * 100; return ( - - - - {new Date(item.timestamp).toISOString().slice(0, 10)} - {percent !== 0 && ( - - 0 ? styles.discountChip : styles.penaltyChip}> - 0 ? styles.discountText : styles.penaltyText}> - {Math.abs(percent).toFixed(2)}% {percent > 0 ? "discount" : "penalty"} applied - - - - )} - - - - {item.currency} {item.amount.toFixed(2)} - - + + {format(new Date(payment.timestamp))} + + Payment + {percent >= 0.01 && ( + + {percent.toFixed(2)}% discount + + )} + {percent <= -0.01 && ( + + {Math.abs(percent).toFixed(2)}% penalty + + )} + -{currency.format(payment.positionAmount)} ); })} - - )} - {purchases.length > 0 && ( - <> - Purchases - {purchases.map((item) => ( - - - - {new Date(item.timestamp).toISOString().slice(0, 10)} - {item.description} - {item.installments.map((installment) => { - const { current, total } = installment; - return ( - - Installment {current} of {total} - - ); - })} - - - - USDC {item.installments.reduce((sum, inst) => sum + inst.amount, 0).toFixed(2)} - - - - - ))} - + + + Payments total + -{currency.format(totalPayments)} + + )} + + Due balance + {currency.format(dueBalance)} + ); @@ -147,8 +210,8 @@ const Statement = ({ export default Statement; -export function format(timestamp: number) { - return new Date(timestamp * 1000).toISOString().slice(0, 10); +export function format(date: Date) { + return date.toLocaleDateString("en-US", { timeZone: "UTC", month: "short", day: "2-digit", year: "numeric" }); } const styles = StyleSheet.create({ @@ -157,61 +220,96 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", - marginBottom: 12, + marginBottom: 16, paddingBottom: 16, borderBottom: "1px solid #E6E9E8", }, headerLeft: { flex: 1 }, - appTitle: { fontSize: 24, fontWeight: "bold", color: "#1A211E", marginBottom: 4 }, - title: { fontSize: 18, fontWeight: "600", color: "#1A211E" }, - discountChipContainer: { flexDirection: "row", alignItems: "center", gap: 6 }, - discountChip: { - backgroundColor: "#E0F8F3", - paddingHorizontal: 6, - paddingVertical: 2, + headerRight: { alignItems: "flex-end" }, + title: { fontSize: 18, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, + headerDetail: { fontSize: 11, color: "#5F6563", marginBottom: 2 }, + headerLabel: { fontWeight: "bold", color: "#5F6563" }, + infoBar: { + flexDirection: "row", + marginBottom: 16, borderRadius: 8, - alignSelf: "flex-start", + border: "1px solid #EEF1F0", + backgroundColor: "#F3F5F4", }, - discountText: { fontSize: 10, color: "#008573", fontWeight: "500" }, - penaltyChip: { - backgroundColor: "#FDE8EA", - paddingHorizontal: 6, - paddingVertical: 2, + infoCell: { flex: 1, padding: 12 }, + infoCellBorder: { flex: 1, padding: 12, borderLeft: "1px solid #EEF1F0" }, + infoLabel: { fontSize: 10, color: "#5F6563", marginBottom: 4 }, + infoValue: { fontSize: 12, color: "#1A211E" }, + summaryBox: { + marginBottom: 16, + paddingTop: 16, + paddingHorizontal: 16, borderRadius: 8, - alignSelf: "flex-start", - }, - penaltyText: { fontSize: 10, color: "#C03445", fontWeight: "500" }, - sectionHeader: { - fontSize: 16, - fontWeight: "bold", - color: "#1A211E", - marginTop: 8, - marginBottom: 8, - paddingLeft: 4, + border: "1px solid #EEF1F0", + backgroundColor: "#FFFFFF", }, - activityItem: { + summaryTitle: { fontSize: 14, fontWeight: "bold", color: "#1A211E", marginBottom: 8 }, + summaryRow: { flexDirection: "row", alignItems: "flex-end", marginBottom: 4 }, + summaryLeader: { flex: 1, borderBottom: "1px dotted #C0C0C0", marginHorizontal: 4, marginBottom: 3 }, + summaryLabel: { fontSize: 12, color: "#1A211E" }, + summaryAmount: { fontSize: 12, color: "#1A211E" }, + summaryDueRow: { flexDirection: "row", - alignItems: "center", - backgroundColor: "#FFFFFF", + justifyContent: "space-between", padding: 16, - marginBottom: 8, - borderRadius: 8, - border: "1px solid #EEF1F0", + marginHorizontal: -16, + backgroundColor: "#F3F5F4", + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + marginTop: 12, + }, + section: { marginBottom: 16 }, + sectionHeader: { fontSize: 14, fontWeight: "bold", color: "#1A211E", marginBottom: 8 }, + tableHeader: { + flexDirection: "row", + paddingVertical: 6, + paddingHorizontal: 8, + borderBottom: "1px solid #E6E9E8", + marginBottom: 4, + fontSize: 11, + color: "#5F6563", + }, + tableRow: { + flexDirection: "row", + paddingVertical: 8, + paddingHorizontal: 8, }, - contentContainer: { flex: 1, marginRight: 16 }, - primaryText: { fontSize: 15, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, - amountContainer: { alignItems: "flex-end" }, - amountText: { fontSize: 15, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, - installmentText: { fontSize: 13, color: "#5F6563", marginBottom: 4 }, - infoSection: { + colDate: { width: 100, fontSize: 11, color: "#5F6563" }, + colDesc: { flex: 1, fontSize: 11, color: "#1A211E" }, + colDescRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 4 }, + descText: { fontSize: 11, color: "#1A211E" }, + discountChip: { backgroundColor: "#E6F4EA", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }, + discountText: { fontSize: 9, color: "#1B7D3A" }, + penaltyChip: { backgroundColor: "#FDE8E8", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }, + penaltyText: { fontSize: 9, color: "#C5221F" }, + colInst: { width: 100, fontSize: 11, color: "#1A211E", textAlign: "center" }, + colTotal: { width: 90, fontSize: 11, color: "#1A211E", textAlign: "right" }, + headerDate: { width: 100, fontSize: 11, color: "#5F6563" }, + headerDesc: { flex: 1, fontSize: 11, color: "#5F6563" }, + headerInst: { width: 100, fontSize: 11, color: "#5F6563", textAlign: "center" }, + headerTotal: { width: 90, fontSize: 11, color: "#5F6563", textAlign: "right" }, + totalRow: { + flexDirection: "row", + paddingVertical: 8, + paddingHorizontal: 8, + borderTop: "1px solid #E6E9E8", + marginTop: 4, + }, + totalLabel: { flex: 1, fontSize: 11, color: "#1A211E" }, + totalAmount: { width: 90, fontSize: 11, color: "#1A211E", textAlign: "right" }, + dueBar: { flexDirection: "row", justifyContent: "space-between", - marginBottom: 16, - paddingBottom: 12, - borderBottom: "1px solid #EEF1F0", + padding: 16, + backgroundColor: "#1A211E", + borderRadius: 8, + marginTop: 8, }, - infoColumn: { flex: 1 }, - infoRow: { flexDirection: "row", marginBottom: 6 }, - infoLabel: { fontSize: 13, fontWeight: "600", color: "#5F6563", width: 80 }, - cardNumber: { fontSize: 13, color: "#1A211E", flex: 1, fontFamily: "Courier" }, + dueLabel: { fontSize: 14, fontWeight: "bold", color: "#FFFFFF" }, + dueAmount: { fontSize: 14, fontWeight: "bold", color: "#FFFFFF" }, }); diff --git a/server/utils/allower.ts b/server/utils/allower.ts new file mode 100644 index 000000000..fb848db41 --- /dev/null +++ b/server/utils/allower.ts @@ -0,0 +1,103 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { captureException, captureMessage } from "@sentry/node"; +import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; +import { parse } from "valibot"; +import { createWalletClient, http, withRetry } from "viem"; + +import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; +import chain, { firewallAbi, firewallAddress } from "@exactly/common/generated/chain"; + +import baseExtender from "./baseExtender"; +import { GOOGLE_APPLICATION_CREDENTIALS, hasCredentials, initializeGcpCredentials, isRetryableKmsError } from "./gcp"; +import nonceManager from "./nonceManager"; +import { captureRequests, Requests } from "./publicClient"; + +import type { Address } from "@exactly/common/validation"; +import type { HttpTransport, LocalAccount, WalletClient } from "viem"; + +if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); +const rpcUrl = chain.rpcUrls.alchemy.http[0]; + +if (!process.env.GCP_PROJECT_ID) throw new Error("GCP_PROJECT_ID is required when using GCP KMS"); +const projectId = process.env.GCP_PROJECT_ID; +if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) { + throw new Error("GCP_PROJECT_ID must be a valid GCP project ID format"); +} + +if (!process.env.GCP_KMS_KEY_RING) throw new Error("GCP_KMS_KEY_RING is required when using GCP KMS"); +const keyRing = process.env.GCP_KMS_KEY_RING; +if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("GCP_KMS_KEY_VERSION is required when using GCP KMS"); +const version = process.env.GCP_KMS_KEY_VERSION; +if (!/^\d+$/.test(version)) throw new Error("GCP_KMS_KEY_VERSION must be a numeric version number"); + +export async function getAccount(): Promise { + await initializeGcpCredentials(); + + if (!(await hasCredentials())) { + throw new Error( + `gcp credentials file not found at ${GOOGLE_APPLICATION_CREDENTIALS}. ` + + `ensure GCP_BASE64_JSON environment variable is set.`, + ); + } + + try { + const account = await withRetry( + () => + gcpHsmToAccount({ + hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${keyRing}/cryptoKeys/allower/cryptoKeyVersions/${version}`, + kmsClient: new KeyManagementServiceClient({ + keyFilename: GOOGLE_APPLICATION_CREDENTIALS, + }), + }), + { + delay: 2000, + retryCount: 3, + shouldRetry: ({ error }) => isRetryableKmsError(error), + }, + ); + + account.nonceManager = nonceManager; + return account; + } catch (error: unknown) { + captureException(error, { level: "error" }); + throw error; + } +} + +export default async function allower() { + return createWalletClient({ + chain, + transport: http(`${rpcUrl}/${alchemyAPIKey}`, { + batch: true, + async onFetchRequest(request) { + try { + captureRequests(parse(Requests, await request.clone().json())); + } catch (error: unknown) { + captureMessage("failed to parse or capture rpc requests", { + level: "error", + extra: { error }, + }); + } + }, + }), + account: await getAccount(), + }).extend((client: WalletClient) => { + const base = baseExtender(client); + return { + ...base, + allow: async (account: Address, options?: { ignore?: string[] }) => { + if (!firewallAddress) throw new Error("firewall address not configured"); + return base.exaSend( + { forceTransaction: true, name: "firewall.allow", op: "exa.firewall", attributes: { account } }, + { + address: firewallAddress, + functionName: "allow", + args: [account, true], + abi: firewallAbi, + }, + options?.ignore ? { ignore: options.ignore } : undefined, + ); + }, + }; + }); +} diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 000000000..c4af9bb24 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,74 @@ +import { captureException } from "@sentry/core"; +import { betterAuth } from "better-auth"; +import { organization, siwe } from "better-auth/plugins"; +import { createAccessControl } from "better-auth/plugins/access"; +import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access"; +import { parse } from "valibot"; +import { verifyMessage } from "viem"; +import { generateSiweNonce } from "viem/siwe"; + +import domain from "@exactly/common/domain"; +import chain from "@exactly/common/generated/chain"; +import { Address, Hex } from "@exactly/common/validation"; + +import authSecret from "./authSecret"; +import { authAdapter } from "../database/index"; +const ac = createAccessControl({ + ...defaultStatements, + webhook: ["create", "delete", "read"], + kyc: ["create", "delete", "read"], +}); + +export default betterAuth({ + database: authAdapter, + baseURL: `https://${domain}`, + trustedOrigins: [`https://${domain}`], + secret: authSecret, + user: { changeEmail: { enabled: true } }, + plugins: [ + siwe({ + domain, + emailDomainName: domain === "localhost" ? "localhost.com" : domain, + anonymous: true, + getNonce: async () => { + return await Promise.resolve(generateSiweNonce()); + }, + verifyMessage: async ({ message, signature, address, chainId }) => { + if (chainId !== chain.id) return false; + try { + const isValid = await verifyMessage({ + address: parse(Address, address), + message, + signature: parse(Hex, signature), + }); + return isValid; + } catch (error) { + captureException(error, { level: "error" }); + return false; + } + }, + }), + organization({ + ac, + roles: { + admin: ac.newRole({ + webhook: ["create", "delete", "read"], + kyc: ["create"], + ...adminAc.statements, + }), + owner: ac.newRole({ + webhook: ["create", "delete", "read"], + kyc: ["create"], + ...ownerAc.statements, + }), + member: ac.newRole({ + ...memberAc.statements, + }), + }, + additionalFields: { + role: { type: "string", required: false, input: false }, + }, + allowUserToCreateOrganization: () => true, + }), + ], +}); diff --git a/server/utils/baseExtender.ts b/server/utils/baseExtender.ts new file mode 100644 index 000000000..946b0ba44 --- /dev/null +++ b/server/utils/baseExtender.ts @@ -0,0 +1,189 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; +import { captureException, startSpan, withScope } from "@sentry/node"; +import { setTimeout } from "node:timers/promises"; +import { + encodeFunctionData, + getContractError, + InvalidInputRpcError, + keccak256, + RawContractError, + ResourceNotFoundRpcError, + WaitForTransactionReceiptTimeoutError, + withRetry, + type HttpTransport, + type LocalAccount, + type MaybePromise, + type Prettify, + type TransactionReceipt, + type WalletClient, + type WriteContractParameters, +} from "viem"; + +import chain from "@exactly/common/generated/chain"; +import revertReason from "@exactly/common/revertReason"; + +import nonceManager from "./nonceManager"; +import publicClient from "./publicClient"; +import revertFingerprint from "./revertFingerprint"; +import traceClient from "./traceClient"; + +import type { Hash } from "@exactly/common/validation"; + +export default function baseExtender( + client: WalletClient & { + account: TAccount; + }, +) { + return { + exaSend: async ( + spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, + call: Prettify>, + options?: { + ignore?: ((reason: string) => MaybePromise) | string[]; + level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; + onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; + }, + ) => + withScope((scope) => + startSpan({ forceTransaction: true, ...spanOptions }, async (span) => { + try { + scope.setContext("tx", { call }); + span.setAttributes({ + "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`, + "tx.from": client.account.address, + "tx.to": call.address, + }); + const txOptions = { + type: "eip1559", + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + gas: 5_000_000n, + } as const; + const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () => + publicClient.simulateContract({ account: client.account, ...txOptions, ...call }), + ); + const { + abi: _, + account: __, + address: ___, + ...request + } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest }; + scope.setContext("tx", { request }); + const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () => + client.prepareTransactionRequest({ + to: call.address, + data: encodeFunctionData(call), + ...txOptions, + nonceManager, + }), + ); + scope.setContext("tx", { request, prepared }); + span.setAttribute("tx.nonce", prepared.nonce); + const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- viem generic type inference limitation + client.signTransaction(prepared as any), + ); + const hash = keccak256(serializedTransaction); + scope.setContext("tx", { request, prepared, hash }); + span.setAttribute("tx.hash", hash); + const abortController = new AbortController(); + const [, receiptResult] = await Promise.allSettled([ + (async () => { + while (!abortController.signal.aborted) { + await Promise.allSettled([ + startSpan({ name: "send transaction", op: "tx.send" }, () => + publicClient.sendRawTransaction({ serializedTransaction }), + ).catch((error: unknown) => { + captureException(error, { level: "error" }); + throw error; + }), + setTimeout(10_000, null, { signal: abortController.signal }), + ]); + } + })(), + startSpan({ name: "wait for receipt", op: "tx.wait" }, () => + publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }), + ) + .catch((error: unknown) => { + if (error instanceof WaitForTransactionReceiptTimeoutError) { + startSpan( + { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } }, + (resetSpan) => { + const info = nonceManager.info({ address: client.account.address, chainId: chain.id }); + resetSpan.setAttribute("exa.reset", true); + resetSpan.setAttribute("exa.delta", info.delta); + resetSpan.setAttribute("exa.nonce", info.nonce); + nonceManager.hardReset({ address: client.account.address, chainId: chain.id }); + }, + ); + } + throw error; + }) + .finally(() => { + abortController.abort(); + }), + Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) => + captureException(error, { level: "error" }), + ), + ]); + if (receiptResult.status === "rejected") throw receiptResult.reason; + const receipt = receiptResult.value; + scope.setContext("tx", { request, receipt }); + const [trace] = await Promise.all([ + startSpan({ name: "trace transaction", op: "tx.trace" }, () => + withRetry(() => traceClient.traceTransaction(hash), { + delay: 1000, + retryCount: 10, + shouldRetry: ({ error }) => + error instanceof InvalidInputRpcError || error instanceof ResourceNotFoundRpcError, + }).catch((error: unknown) => { + captureException(error, { level: "error" }); + return null; + }), + ), + Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) => + captureException(error, { level: "error" }), + ), + ]); + scope.setContext("tx", { request, receipt, trace }); + if (receipt.status !== "success") { + if (!trace) throw new Error("no trace"); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error + throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] }); + } + span.setStatus({ code: SPAN_STATUS_OK }); + return receipt; + } catch (error: unknown) { + const reason = revertReason(error, { fallback: "message", withArguments: true }); + if (options?.ignore) { + const ignore = + typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason); + if (ignore) { + span.setAttribute("exa.error", reason); + span.setStatus({ code: SPAN_STATUS_OK }); + return ignore === true ? null : ignore; + } + } + span.setStatus({ code: SPAN_STATUS_ERROR, message: reason }); + const level = + typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error"); + if (level) { + withScope((captureScope) => { + const fingerprint = revertFingerprint(error); + if (fingerprint[1] && fingerprint[1] !== "unknown") { + const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1]; + captureScope.addEventProcessor((event) => { + if (event.exception?.values?.[0]) event.exception.values[0].type = type; + return event; + }); + } + captureException(error, { level, fingerprint }); + }); + } + throw error; + } + }), + ), + }; +} diff --git a/server/utils/createCredential.ts b/server/utils/createCredential.ts index 9d6652ebe..e5f062bfb 100644 --- a/server/utils/createCredential.ts +++ b/server/utils/createCredential.ts @@ -24,13 +24,14 @@ import type { Context } from "hono"; export default async function createCredential( c: Context, credentialId: C, - options?: { source?: string; webauthn?: WebAuthnCredential }, + options?: { factory?: Address; source?: string; webauthn?: WebAuthnCredential }, ) { + const factory = options?.factory ?? exaAccountFactoryAddress; const publicKey = options?.webauthn?.publicKey ?? (isAddress(credentialId) ? new Uint8Array(hexToBytes(credentialId)) : undefined); if (!publicKey) throw new Error("bad credential"); const { x, y } = decodePublicKey(publicKey); - const account = deriveAddress(exaAccountFactoryAddress, { x, y }); + const account = deriveAddress(factory, { x, y }); setUser({ id: account }); const expires = new Date(Date.now() + AUTH_EXPIRY); @@ -39,7 +40,7 @@ export default async function createCredential( account, id: credentialId, publicKey, - factory: exaAccountFactoryAddress, + factory, transports: options?.webauthn?.transports, counter: options?.webauthn?.counter, source: options?.source, @@ -63,5 +64,5 @@ export default async function createCredential( }).catch((error: unknown) => captureException(error, { level: "error" })), ]); identify({ userId: account }); - return { credentialId, factory: parse(Address, exaAccountFactoryAddress), x, y, auth: expires.getTime() }; + return { credentialId, factory: parse(Address, factory), x, y, auth: expires.getTime() }; } diff --git a/server/utils/gcp.ts b/server/utils/gcp.ts new file mode 100644 index 000000000..cadfbde95 --- /dev/null +++ b/server/utils/gcp.ts @@ -0,0 +1,72 @@ +import { access, writeFile } from "node:fs/promises"; +import { number, object, safeParse, string } from "valibot"; + +const DECODING_ITERATIONS = 3; +export const GOOGLE_APPLICATION_CREDENTIALS = "/tmp/gcp-service-account.json"; + +if (!process.env.GCP_BASE64_JSON) throw new Error("GCP_BASE64_JSON is required when using GCP KMS"); +const gcpBase64Json = process.env.GCP_BASE64_JSON; + +let initializationPromise: null | Promise = null; + +export function resetGcpInitialization() { + initializationPromise = null; +} + +export async function initializeGcpCredentials() { + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + if (await hasCredentials()) { + return; + } + + let json = gcpBase64Json; + for (let index = 0; index < DECODING_ITERATIONS; index++) { + json = Buffer.from(json, "base64").toString("utf8"); + } + await writeFile(GOOGLE_APPLICATION_CREDENTIALS, json, { mode: 0o600 }); + })().catch((error: unknown) => { + initializationPromise = null; + throw error; + }); + + return initializationPromise; +} + +export async function hasCredentials(): Promise { + return access(GOOGLE_APPLICATION_CREDENTIALS) + .then(() => true) + .catch(() => false); +} + +export function isRetryableKmsError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const numericResult = safeParse(object({ code: number() }), error); + if (numericResult.success) { + const code = numericResult.output.code; + return code === 14 || code === 4 || code === 13 || code === 8; + } + + const stringResult = safeParse(object({ code: string() }), error); + if (stringResult.success) { + const code = stringResult.output.code; + return ( + code === "UNAVAILABLE" || code === "DEADLINE_EXCEEDED" || code === "INTERNAL" || code === "RESOURCE_EXHAUSTED" + ); + } + + const message = error.message.toLowerCase(); + return ( + message.includes("network") || + message.includes("timeout") || + message.includes("unavailable") || + message.includes("internal error") || + message.includes("service unavailable") || + error.name === "NetworkError" || + error.name === "TimeoutError" + ); +} diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 4735ff613..30350d169 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -1,36 +1,32 @@ -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; -import { captureException, startSpan, withScope } from "@sentry/node"; -import { setTimeout } from "node:timers/promises"; +import { captureException } from "@sentry/node"; import { parse } from "valibot"; import { createWalletClient, - encodeFunctionData, - getContractError, + erc20Abi, http, - InvalidInputRpcError, - keccak256, - RawContractError, - WaitForTransactionReceiptTimeoutError, withRetry, type HttpTransport, - type MaybePromise, - type Prettify, type PrivateKeyAccount, - type TransactionReceipt, type WalletClient, - type WriteContractParameters, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; -import chain from "@exactly/common/generated/chain"; -import revertReason from "@exactly/common/revertReason"; -import { Hash } from "@exactly/common/validation"; +import chain, { + auditorAbi, + exaPluginAbi, + exaPreviewerAbi, + exaPreviewerAddress, + marketAbi, + upgradeableModularAccountAbi, + wethAddress, +} from "@exactly/common/generated/chain"; +import { Address, Hash } from "@exactly/common/validation"; +import baseExtender from "./baseExtender"; import nonceManager from "./nonceManager"; +import { sendPushNotification } from "./onesignal"; import publicClient, { captureRequests, Requests } from "./publicClient"; -import revertFingerprint from "./revertFingerprint"; -import traceClient from "./traceClient"; if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); @@ -50,149 +46,107 @@ export default createWalletClient({ ), }).extend(extender); +const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); +const WETH = parse(Address, wethAddress); + export function extender(keeper: WalletClient) { + const base = baseExtender(keeper); + return { - exaSend: async ( - spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, - call: Prettify>, - options?: { - ignore?: ((reason: string) => MaybePromise) | string[]; - level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; - onHash?: (hash: Hash) => MaybePromise; - }, - ) => - withScope((scope) => - startSpan({ forceTransaction: true, ...spanOptions }, async (span) => { - try { - scope.setContext("tx", { call }); - span.setAttributes({ - "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`, - "tx.from": keeper.account.address, - "tx.to": call.address, - }); - const txOptions = { - type: "eip1559", - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, - gas: 5_000_000n, - } as const; - const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () => - publicClient.simulateContract({ account: keeper.account, ...txOptions, ...call }), - ); - const { - abi: _, - account: __, - address: ___, - ...request - } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest }; - scope.setContext("tx", { request }); - const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () => - keeper.prepareTransactionRequest({ - to: call.address, - data: encodeFunctionData(call), - ...txOptions, - nonceManager, - }), - ); - scope.setContext("tx", { request, prepared }); - span.setAttribute("tx.nonce", prepared.nonce); - const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () => - keeper.signTransaction(prepared), - ); - const hash = keccak256(serializedTransaction); - scope.setContext("tx", { request, prepared, hash }); - span.setAttribute("tx.hash", hash); - const abortController = new AbortController(); - const [, receiptResult] = await Promise.allSettled([ - (async () => { - while (!abortController.signal.aborted) { - await Promise.allSettled([ - startSpan({ name: "send transaction", op: "tx.send" }, () => - publicClient.sendRawTransaction({ serializedTransaction }), - ).catch((error: unknown) => { - captureException(error, { level: "error" }); - throw error; - }), - setTimeout(10_000, null, { signal: abortController.signal }), - ]); - } - })(), - startSpan({ name: "wait for receipt", op: "tx.wait" }, () => - publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }), - ) - .catch((error: unknown) => { - if (error instanceof WaitForTransactionReceiptTimeoutError) { - startSpan( - { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } }, - (resetSpan) => { - const info = nonceManager.info({ address: keeper.account.address, chainId: chain.id }); - resetSpan.setAttribute("exa.reset", true); - resetSpan.setAttribute("exa.delta", info.delta); - resetSpan.setAttribute("exa.nonce", info.nonce); - nonceManager.hardReset({ address: keeper.account.address, chainId: chain.id }); - }, - ); - } - throw error; - }) - .finally(() => { - abortController.abort(); - }), - Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) => - captureException(error, { level: "error" }), + ...base, + poke: async ( + accountAddress: Address, + options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } }, + ) => { + const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; + const marketsByAsset = await publicClient + .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) + .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)]))); + + const assetsToPoke: { asset: Address; market: Address | null }[] = []; + + const settled = await Promise.allSettled([ + publicClient + .getBalance({ address: accountAddress }) + .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({ + asset: ETH, + market: null, + balance, + })), + ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({ + asset, + market, + balance: await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }), + })), + ]).then((s) => { + return s.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; + } + return [result.value]; + }); + }); + + const hasETH = settled.some((r) => r.asset === ETH && r.balance > 0n); + for (const { asset, market, balance } of settled) { + if (hasETH && asset === WETH) continue; + if (balance > 0n) assetsToPoke.push({ asset, market }); + } + + const pokes = await Promise.allSettled( + assetsToPoke.map(({ asset, market }) => + withRetry( + () => + base.exaSend( + { + name: "poke account", + op: "exa.poke", + attributes: { account: accountAddress, asset }, + }, + asset === ETH + ? { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "pokeETH", + } + : { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "poke", + args: [market], + }, + ...(options?.ignore ? [{ ignore: options.ignore }] : []), ), - ]); - if (receiptResult.status === "rejected") throw receiptResult.reason; - const receipt = receiptResult.value; - scope.setContext("tx", { request, receipt }); - const trace = await startSpan({ name: "trace transaction", op: "tx.trace" }, () => - withRetry(() => traceClient.traceTransaction(hash), { - delay: 1000, - retryCount: 10, - shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, - }).catch((error: unknown) => { - captureException(error, { level: "error" }); - return null; - }), - ); - scope.setContext("tx", { request, receipt, trace }); - if (receipt.status !== "success") { - if (!trace) throw new Error("no trace"); - // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error - throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] }); - } - span.setStatus({ code: SPAN_STATUS_OK }); - return receipt; - } catch (error: unknown) { - const reason = revertReason(error, { fallback: "message", withArguments: true }); - if (options?.ignore) { - const ignore = - typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason); - if (ignore) { - span.setAttribute("exa.error", reason); - span.setStatus({ code: SPAN_STATUS_OK }); - return ignore === true ? null : ignore; - } - } - span.setStatus({ code: SPAN_STATUS_ERROR, message: reason }); - const level = - typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error"); - if (level) { - withScope((captureScope) => { - const fingerprint = revertFingerprint(error); - if (fingerprint[1] && fingerprint[1] !== "unknown") { - const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1]; - captureScope.addEventProcessor((event) => { - if (event.exception?.values?.[0]) event.exception.values[0].type = type; - return event; - }); - } - captureException(error, { level, fingerprint }); - }); - } - throw error; + { + retryCount: 10, + delay: ({ count }) => Math.trunc(1 << count) * 60, + }, + ), + ), + ).then((r) => { + return r.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; } - }), - ), + + return result.value ?? []; + }); + }); + + if (options?.notification && pokes.length > 0) { + sendPushNotification({ + userId: accountAddress, + headings: options.notification.headings, + contents: options.notification.contents, + }).catch((error: unknown) => captureException(error, { level: "error" })); + } + }, }; } diff --git a/server/utils/onesignal.ts b/server/utils/onesignal.ts index b84868d15..8a58d0786 100644 --- a/server/utils/onesignal.ts +++ b/server/utils/onesignal.ts @@ -1,6 +1,6 @@ import { createConfiguration, DefaultApi, Notification } from "@onesignal/node-onesignal"; -import appId from "@exactly/common/onesignalAppId"; +import appId from "@exactly/common/onesignalAppId.web"; const client = new DefaultApi(createConfiguration({ restApiKey: process.env.ONESIGNAL_API_KEY })); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..3a52b4cb4 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -3,22 +3,33 @@ import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; import { eq } from "drizzle-orm"; import { boolean, + check, + email, + ipv4, + ipv6, length, literal, maxLength, + metadata, minLength, nullable, number, object, + omit, + optional, parse, + partial, picklist, pipe, + regex, string, transform, + union, type BaseIssue, type BaseSchema, + type InferInput, } from "valibot"; -import { BaseError, ContractFunctionZeroDataError } from "viem"; +import { BaseError, ContractFunctionZeroDataError, type MaybePromise } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, optimism } from "viem/chains"; @@ -32,7 +43,7 @@ import chain, { upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import { Address, Hash } from "@exactly/common/validation"; +import { Address, Hash, Hex } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; import ServiceError from "./ServiceError"; @@ -47,7 +58,6 @@ const baseURL = process.env.PANDA_API_URL; if (!process.env.PANDA_API_KEY) throw new Error("missing panda api key"); const key = process.env.PANDA_API_KEY; -export default key; export async function createCard(userId: string, productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID) { return await request( @@ -60,10 +70,13 @@ export async function createCard(userId: string, productId: typeof PLATINUM_PROD limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, configuration: { productId, - virtualCardArt: { - [PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e", - [SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816", - }[productId], + virtualCardArt: + baseURL === "https://api-dev.rain.xyz/v1" + ? "0c515d7eb0a140fa8f938f8242b0780a" + : { + [PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e", + [SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816", + }[productId], }, }), "POST", @@ -164,6 +177,7 @@ async function request>( body?: unknown, method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, + onError?: (response: Response) => MaybePromise, ) { const response = await fetch(`${baseURL}${url}`, { method, @@ -234,13 +248,11 @@ const CreateCardRequest = object({ configuration: object({ productId: picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), virtualCardArt: string() }), }); -export const CardStatus = picklist(["active", "canceled", "locked", "notActivated"]); - const CardResponse = object({ id: string(), userId: string(), type: literal("virtual"), - status: CardStatus, + status: picklist(["active", "canceled", "locked", "notActivated"]), limit: object({ amount: number(), frequency: picklist([ @@ -366,3 +378,182 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } + +export async function submitApplication(payload: InferInput, encrypted = false) { + return request( + ApplicationResponse, + "/issuing/applications/user", + { ...(encrypted && { encrypted: "true" }) }, + payload, + "POST", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); +} + +export async function getApplicationStatus(applicationId: string) { + return request( + ApplicationStatusResponse, + `/issuing/applications/user/${applicationId}`, + {}, + undefined, + "GET", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); +} + +export async function updateApplication(applicationId: string, payload: InferInput) { + return request( + object({}), + `/issuing/applications/user/${applicationId}`, + {}, + payload, + "PATCH", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); +} + +const AddressSchema = object({ + line1: pipe(string(), minLength(1), maxLength(100)), + line2: optional(pipe(string(), minLength(1), maxLength(100))), + city: pipe(string(), minLength(1), maxLength(50)), + region: pipe(string(), minLength(1), maxLength(50)), + country: optional(pipe(string(), minLength(1), maxLength(50))), + postalCode: pipe(string(), minLength(1), maxLength(15), regex(/^[a-z0-9]{1,15}$/i)), + countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), +}); + +export const Application = object({ + email: pipe( + string(), + email("Invalid email address"), + metadata({ description: "Email address", examples: ["user@domain.com"] }), + ), + lastName: pipe(string(), maxLength(50), metadata({ description: "The person's last name" })), + firstName: pipe(string(), maxLength(50), metadata({ description: "The person's first name" })), + nationalId: pipe(string(), maxLength(50), metadata({ description: "The person's national ID" })), + birthDate: pipe( + string(), + regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format"), + check((value) => { + const date = new Date(value); + return !Number.isNaN(date.getTime()); + }, "must be a valid date"), + metadata({ description: "Birth date (YYYY-MM-DD)", examples: ["1970-01-01"] }), + ), + countryOfIssue: pipe( + string(), + length(2), + regex(/^[A-Z]{2}$/i, "Must be exactly 2 letters"), + metadata({ description: "The person's country of issue of their national id, as a 2-digit country code" }), + ), + phoneCountryCode: pipe( + string(), + minLength(1), + maxLength(3), + regex(/^\d{1,3}$/, "Must be a valid country code"), + metadata({ description: "The user's phone country code" }), + ), + phoneNumber: pipe( + string(), + minLength(1), + maxLength(15), + regex(/^\d{1,15}$/, "Must be a valid phone number"), + metadata({ description: "The user's phone number" }), + ), + address: pipe(AddressSchema, metadata({ description: "The person's address" })), + ipAddress: pipe( + union([pipe(string(), maxLength(50), ipv4()), pipe(string(), maxLength(50), ipv6())]), + metadata({ description: "The user's IP address (IPv4 or IPv6)" }), + ), + occupation: pipe(string(), maxLength(50), metadata({ description: "The user's occupation" })), + annualSalary: pipe(string(), maxLength(50), metadata({ description: "The user's annual salary" })), + accountPurpose: pipe(string(), maxLength(50), metadata({ description: "The user's account purpose" })), + expectedMonthlyVolume: pipe(string(), maxLength(50), metadata({ description: "The user's expected monthly volume" })), + isTermsOfServiceAccepted: pipe( + boolean(), + literal(true), + metadata({ description: "Whether the user has accepted the terms of service" }), + ), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), +}); + +export const SubmitApplicationRequest = union([ + Application, + object({ + key: string(), + iv: string(), + ciphertext: string(), + tag: string(), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), + }), +]); + +export const UpdateApplicationRequest = object({ + ...partial(omit(Application, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + address: optional(AddressSchema), +}); + +const ApplicationResponse = object({ + id: pipe(string(), maxLength(50)), + applicationStatus: pipe(string(), maxLength(50)), +}); + +export const kycStatus = [ + "needsVerification", + "needsInformation", + "manualReview", + "notStarted", + "approved", + "canceled", + "pending", + "denied", + "locked", +] as const; + +const ApplicationStatusResponse = object({ + id: string(), + applicationStatus: picklist(kycStatus), + applicationReason: optional(string()), +}); + +export class KycError extends Error { + constructor( + message: string, + public statusCode: number, + ) { + super(message); + this.name = "KycError"; + } +} + +// #endregion schemas diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 42b38682c..35478d410 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -19,7 +19,7 @@ import { type InferInput, type InferOutput, } from "valibot"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -283,8 +283,6 @@ const SupportedOnRampChainId: Record< (typeof CryptoPaymentRail)[number] | undefined > = { [optimism.id]: "optimism", - [base.id]: "base", - [baseSepolia.id]: "base", [optimismSepolia.id]: "optimism", } as const; diff --git a/server/utils/ramps/manteca.ts b/server/utils/ramps/manteca.ts index 1b0c86fcf..84a34cbdb 100644 --- a/server/utils/ramps/manteca.ts +++ b/server/utils/ramps/manteca.ts @@ -15,7 +15,7 @@ import { type InferOutput, } from "valibot"; import { withRetry } from "viem"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -376,13 +376,11 @@ export async function mantecaOnboarding(account: Address, credentialId: string) // #endregion services // #region schemas -const Networks = ["OPTIMISM", "BASE"] as const; +const Networks = ["OPTIMISM"] as const; const SupportedOnRampChainId: Record<(typeof shared.SupportedChainId)[number], (typeof Networks)[number] | undefined> = { [optimism.id]: "OPTIMISM", - [base.id]: "BASE", - [baseSepolia.id]: "BASE", [optimismSepolia.id]: "OPTIMISM", } as const; diff --git a/server/utils/ramps/shared.ts b/server/utils/ramps/shared.ts index 81d5b1e6b..cfee663ff 100644 --- a/server/utils/ramps/shared.ts +++ b/server/utils/ramps/shared.ts @@ -1,12 +1,12 @@ import { array, literal, object, optional, picklist, string, variant } from "valibot"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; export const Currency = ["ARS", "USD", "CLP", "BRL", "COP", "PUSD", "CRC", "GTQ", "MXN", "PHP", "BOB", "EUR"] as const; export const Cryptocurrency = ["USDC", "USDT", "ETH", "SOL", "BTC", "DAI", "PYUSD", "USDP"] as const; // cspell:ignore usdp export const RampProvider = ["manteca", "bridge"] as const; -export const SupportedChainId = [optimism.id, base.id, baseSepolia.id, optimismSepolia.id] as const; -export const DevelopmentChainIds = [baseSepolia.id, optimismSepolia.id] as const; +export const SupportedChainId = [optimism.id, optimismSepolia.id] as const; +export const DevelopmentChainIds = [optimismSepolia.id] as const; export const FiatNetwork = [ "ARG_FIAT_TRANSFER", diff --git a/server/utils/validFactories.ts b/server/utils/validFactories.ts new file mode 100644 index 000000000..ef762d233 --- /dev/null +++ b/server/utils/validFactories.ts @@ -0,0 +1,44 @@ +import { encodeAbiParameters, encodePacked, getAddress, keccak256, slice, type Address, type Hash } from "viem"; +import { baseSepolia, optimismSepolia } from "viem/chains"; + +import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; +import deploy from "@exactly/plugin/deploy.json"; + +const PROXY_INIT_CODE_HASH = "0x21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f" as const; + +const create3Factory: Address = + chain.id === optimismSepolia.id + ? "0xcc3f41204a1324DD91F1Dbfc46208535293A371e" + : chain.id === baseSepolia.id + ? "0x9f275F6D25232FFf082082a53C62C6426c1cc94C" + : "0x93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1"; + +const admin = getAddress( + (deploy.accounts.admin as Record)[String(chain.id)] ?? deploy.accounts.admin.default, +); + +const validFactories = new Set( + ["1.0.0", "1.1.0"].map((version) => + deriveCreate3( + admin, + keccak256(encodeAbiParameters([{ type: "string" }, { type: "string" }], ["Exa Plugin", version])), + ), + ), +); + +if (!validFactories.has(exaAccountFactoryAddress)) throw new Error("missing latest factory"); + +export default validFactories; + +function deriveCreate3(deployer: Address, salt: Hash): Address { + const proxy = slice( + keccak256( + encodePacked( + ["uint8", "address", "bytes32", "bytes32"], + [0xff, create3Factory, keccak256(encodePacked(["address", "bytes32"], [deployer, salt])), PROXY_INIT_CODE_HASH], + ), + ), + 12, + ); + return getAddress(slice(keccak256(encodePacked(["bytes2", "address", "uint8"], ["0xd694", proxy, 0x01])), 12)); +} diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 9b1893b71..f52f9e363 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -19,6 +19,10 @@ export default defineConfig({ BRIDGE_API_KEY: "bridge", BRIDGE_API_URL: "https://bridge.test", EXPO_PUBLIC_ALCHEMY_API_KEY: " ", + GCP_BASE64_JSON: "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K", + GCP_KMS_KEY_RING: "op-sepolia", + GCP_KMS_KEY_VERSION: "1", + GCP_PROJECT_ID: "exa-dev", INTERCOM_IDENTITY_KEY: "a9cBeTfEtGPSQ58REZP35Bx00ofajvStEc8TTuBtSmk", ISSUER_PRIVATE_KEY: padHex("0x420"), MANTECA_API_URL: "https://manteca.test", diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx index 1984503e0..928648854 100644 --- a/src/app/(auth)/_layout.tsx +++ b/src/app/(auth)/_layout.tsx @@ -1,3 +1,5 @@ +import "../../utils/server"; + import React, { useCallback, useEffect } from "react"; import { Platform } from "react-native"; @@ -6,9 +8,13 @@ import Head from "expo-router/head"; import { sdk } from "@farcaster/miniapp-sdk"; import { useQuery } from "@tanstack/react-query"; +import { getConnection } from "@wagmi/core"; +import { proxy } from "comlink"; +import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import useBackgroundColor from "../../utils/useBackgroundColor"; +import exaConfig from "../../utils/wagmi/exa"; import type { Credential } from "@exactly/common/validation"; @@ -20,7 +26,17 @@ export default function OnboardingLayout() { useEffect(() => { if (isLoading || !isFetched) return; - if (isMiniApp) sdk.actions.ready().catch(reportError); + if (isMiniApp) { + sdk.actions + .ready( + // @ts-expect-error ready takes no arguments + proxy({ + getAddress: () => getConnection(exaConfig).address, + hasCard: async () => !!(await queryClient.fetchQuery({ queryKey: ["card", "details"] })), + }), + ) + .catch(reportError); + } SplashScreen.hideAsync().catch(reportError); }, [isFetched, isLoading, isMiniApp]); diff --git a/src/app/+html.tsx b/src/app/+html.tsx index 4865b179b..3e9643faa 100644 --- a/src/app/+html.tsx +++ b/src/app/+html.tsx @@ -25,6 +25,7 @@ export default function HTML({ children }: { children: ReactNode }) { name="fc:miniapp" content={`{"version":"1","imageUrl":"https://assets.exactly.app/miniapp-image.webp","button":{"title":"Get your card","action":{"type":"launch_miniapp","name":"${appMetadata.title}","url":"https://${domain}"}}}`} /> + diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index fcba9e221..6667397fc 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -9,7 +9,7 @@ import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { switchChain, waitForTransactionReceipt } from "@wagmi/core"; +import { switchChain, waitForCallsStatus, waitForTransactionReceipt } from "@wagmi/core"; import { encodeFunctionData, erc20Abi, @@ -33,7 +33,7 @@ import AssetSelectSheet from "./AssetSelectSheet"; import { getBridgeSources, getRouteFrom, tokenCorrelation, type BridgeSources, type RouteFrom } from "../../utils/lifi"; import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; -import reportError from "../../utils/reportError"; +import reportError, { isPasskeyCancelled } from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import ownerConfig from "../../utils/wagmi/owner"; import AssetLogo from "../shared/AssetLogo"; @@ -116,7 +116,7 @@ export default function Bridge() { const previousSourceRef = useRef(undefined); - const effectiveSource = useMemo(() => { + const source = useMemo(() => { if (assetGroups.length === 0) return; const isValid = !!selectedSource && @@ -135,8 +135,8 @@ export default function Bridge() { if (group && asset) return { chain: group.chain.id, address: asset.token.address }; }, [assetGroups, selectedSource, bridge?.defaultChainId, bridge?.defaultTokenAddress]); - const selectedGroup = assetGroups.find((group) => group.chain.id === effectiveSource?.chain); - const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === effectiveSource?.address); + const selectedGroup = assetGroups.find((group) => group.chain.id === source?.chain); + const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === source?.address); const sourceToken = selectedAsset?.token; const sourceBalance = selectedAsset?.balance ?? 0n; @@ -144,8 +144,8 @@ export default function Bridge() { const sourceTokenSymbol = sourceToken?.symbol; const insufficientBalance = sourceAmount > sourceBalance; - const isSameChain = effectiveSource?.chain === chain.id; - const isNativeSource = effectiveSource?.address === zeroAddress; + const isSameChain = source?.chain === chain.id; + const isNativeSource = source?.address === zeroAddress; const destinationTokens = useMemo(() => bridge?.tokensByChain[chain.id] ?? [], [bridge?.tokensByChain]); const destinationBalances = useMemo(() => bridge?.balancesByChain[chain.id] ?? [], [bridge?.balancesByChain]); @@ -198,7 +198,7 @@ export default function Bridge() { const bridgeQuoteEnabled = !!senderAddress && !!account && - !!effectiveSource && + !!source && !!sourceToken && !!destinationToken && sourceAmount > 0n && @@ -215,32 +215,37 @@ export default function Bridge() { "quote", senderAddress, account, - effectiveSource, + source, sourceToken, destinationToken, sourceAmount, isSameChain, ], - queryFn: () => { + queryFn: async () => { if ( !senderAddress || !account || - !effectiveSource || + !source || !sourceToken || !destinationToken || sourceAmount === 0n || isSameChain ) throw new Error("invalid bridge parameters"); - return getRouteFrom({ - fromChainId: effectiveSource.chain, - toChainId: chain.id, - fromTokenAddress: sourceToken.address, - toTokenAddress: destinationToken.address, - fromAmount: sourceAmount, - fromAddress: senderAddress, - toAddress: account, - }); + try { + return await getRouteFrom({ + fromChainId: source.chain, + toChainId: chain.id, + fromTokenAddress: sourceToken.address, + toTokenAddress: destinationToken.address, + fromAmount: sourceAmount, + fromAddress: senderAddress, + toAddress: account, + }); + } catch (error) { + reportError(error, { level: "warning" }); + throw error; + } }, enabled: bridgeQuoteEnabled, refetchInterval: 15_000, @@ -263,22 +268,27 @@ export default function Bridge() { isPending: isSimulatingTransfer, } = useSimulateContract({ config: senderConfig, - chainId: transferSimulationEnabled ? effectiveSource.chain : undefined, - address: transferSimulationEnabled ? getAddress(effectiveSource.address) : undefined, + account: senderAddress, + chainId: transferSimulationEnabled ? source.chain : undefined, + address: transferSimulationEnabled ? getAddress(source.address) : undefined, abi: erc20Abi, functionName: "transfer", args: transferSimulationEnabled ? ([getAddress(account), sourceAmount] as const) : undefined, query: { enabled: transferSimulationEnabled }, }); - const approvalTokenAddress = - effectiveSource?.address && isAddress(effectiveSource.address) ? effectiveSource.address : undefined; + useEffect(() => { + if (transferSimulationError) reportError(transferSimulationError, { level: "warning" }); + }, [transferSimulationError]); + + const approvalTokenAddress = source?.address && isAddress(source.address) ? source.address : undefined; const approvalSpenderAddress = bridgeQuote?.estimate.approvalAddress; const approvalChainId = bridgeQuote?.chainId; const canReadAllowance = !!senderAddress && !!approvalTokenAddress && + approvalTokenAddress !== zeroAddress && !!approvalChainId && !!approvalSpenderAddress && approvalSpenderAddress !== zeroAddress && @@ -308,19 +318,15 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount: BigInt(route.estimate.fromAmount) }); }, mutationFn: async (from) => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing bridge context"); + if (!senderAddress || !source || !account) throw new Error("missing bridge context"); if (isSameChain) throw new Error("invalid bridge context"); - - setBridgeStatus(t("Switching to {{chain}}...", { chain: selectedGroup?.chain.name ?? `Chain ${from.chainId}` })); - await switchChain(senderConfig, { chainId: from.chainId }); - const spender = from.estimate.approvalAddress; const requiresApproval = !!spender && spender !== zeroAddress && - effectiveSource.address !== zeroAddress && + source.address !== zeroAddress && isAddress(spender) && - isAddress(effectiveSource.address); + isAddress(source.address); let approval: Hex | undefined; let currentAllowance = allowanceData; @@ -347,24 +353,41 @@ export default function Bridge() { } } setBridgeStatus(t("Submitting bridge transaction...")); + let id: string | undefined; try { - await sendCallsTx({ + const result = await sendCallsTx({ + chainId: source.chain, calls: [ - ...(approval ? [{ to: getAddress(effectiveSource.address), data: approval }] : []), + ...(approval ? [{ to: getAddress(source.address), data: approval }] : []), { to: from.to, data: from.data, value: from.value }, ], }); - setBridgeStatus(t("Bridge transaction submitted")); + id = result.id; } catch (error) { - reportError(error); - if (approval) { - const hash = await sendTx({ to: getAddress(effectiveSource.address), data: approval }); - await waitForTransactionReceipt(senderConfig, { hash }); + if ( + error instanceof UserRejectedRequestError || + (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") + ) + throw error; + reportError(error, { level: "warning" }); + await switchChain(senderConfig, { chainId: source.chain }); + try { + if (approval) { + const hash = await sendTx({ chainId: source.chain, to: getAddress(source.address), data: approval }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); + } + const hash = await sendTx({ chainId: source.chain, to: from.to, data: from.data, value: from.value }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); + } finally { + await switchChain(senderConfig, { chainId: chain.id }).catch(reportError); } - const hash = await sendTx({ to: from.to, data: from.data, value: from.value }); - await waitForTransactionReceipt(senderConfig, { hash }); setBridgeStatus(t("Bridge transaction submitted")); + return; } + if (!id) throw new Error("missing sendCalls id"); + const { status } = await waitForCallsStatus(senderConfig, { id }); + if (status === "failure") throw new Error("failed to submit bridge transaction"); + setBridgeStatus(t("Bridge transaction submitted")); }, onSuccess: async () => { toast.show(t("Bridge transaction submitted"), { @@ -396,20 +419,19 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount }); }, mutationFn: async () => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing transfer context"); + if (!senderAddress || !source || !account) throw new Error("missing transfer context"); if (!isSameChain) throw new Error("transfer mutation invoked for different chains"); - - await switchChain(senderConfig, { chainId: effectiveSource.chain }); + await switchChain(senderConfig, { chainId: source.chain }); setBridgeStatus(t("Submitting transfer transaction...")); const recipient = getAddress(account); let hash: Hex; if (isNativeSource) { - hash = await sendTx({ to: recipient, value: sourceAmount }); + hash = await sendTx({ chainId: source.chain, to: recipient, value: sourceAmount }); } else { if (!transferSimulation) throw new Error("missing transfer simulation"); - hash = await transfer(transferSimulation.request); + hash = await transfer({ ...transferSimulation.request, chainId: source.chain }); } - await waitForTransactionReceipt(senderConfig, { hash }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); setBridgeStatus(t("Transfer transaction submitted")); }, onSuccess: async () => { @@ -789,8 +811,7 @@ export default function Bridge() { {t("Source network")} - {selectedGroup?.chain.name ?? - (effectiveSource?.chain ? t("Chain {{id}}", { id: effectiveSource.chain }) : "—")} + {selectedGroup?.chain.name ?? (source?.chain ? t("Chain {{id}}", { id: source.chain }) : "—")} @@ -960,7 +981,7 @@ export default function Bridge() { setAssetSheetOpen(false); }} groups={assetGroups} - selected={effectiveSource} + selected={source} onSelect={(chainId, token) => { setSourceAmount(0n); setSelectedSource({ chain: chainId, address: token.address }); @@ -988,6 +1009,7 @@ export default function Bridge() { function handleError(error: unknown, toast: ReturnType, t: TFunction, isTransfer?: boolean) { if (error instanceof UserRejectedRequestError) return; if (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") return; + if (isPasskeyCancelled(error)) return; toast.show(isTransfer ? t("Transfer failed. Please try again.") : t("Bridge failed. Please try again."), { native: true, duration: 1000, diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index c7cbb7b8a..95184b3fe 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -11,7 +11,7 @@ import { ScrollView, Separator, Spinner, Square, Switch, XStack, YStack } from " import { useMutation, useQuery } from "@tanstack/react-query"; import accountInit from "@exactly/common/accountInit"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -99,6 +99,7 @@ export default function Card() { const { refetch: refetchInstalledPlugins, isFetching: isFetchingPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, @@ -110,6 +111,7 @@ export default function Card() { isFetching: isFetchingMarkets, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/card/exa-card/CardContents.tsx b/src/components/card/exa-card/CardContents.tsx index 8281eb36f..ab136c8cb 100644 --- a/src/components/card/exa-card/CardContents.tsx +++ b/src/components/card/exa-card/CardContents.tsx @@ -6,7 +6,7 @@ import { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { Loader, LockKeyhole, Snowflake } from "@tamagui/lucide-icons"; import { AnimatePresence, XStack, YStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { PLATINUM_PRODUCT_ID } from "@exactly/common/panda"; import { borrowLimit, withdrawLimit } from "@exactly/lib"; @@ -34,6 +34,7 @@ export default function CardContents({ const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/defi/DeFi.tsx b/src/components/defi/DeFi.tsx index c8840eead..aba21cfbd 100644 --- a/src/components/defi/DeFi.tsx +++ b/src/components/defi/DeFi.tsx @@ -10,6 +10,8 @@ import { ScrollView, useTheme, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import AboutDefiSheet from "./AboutDefiSheet"; import ConnectionSheet from "./ConnectionSheet"; import DisconnectSheet from "./DisconnectSheet"; @@ -32,7 +34,7 @@ export default function DeFi() { const { data: fundingConnected } = useQuery({ queryKey: ["defi", "usdc-funding-connected"] }); const { data: lifiConnected } = useQuery({ queryKey: ["defi", "lifi-connected"] }); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const [aboutDefiSheetOpen, setAboutDefiSheetOpen] = useState(false); const [fundingSheetOpen, setFundingSheetOpen] = useState(false); const [lifiSheetOpen, setLifiSheetOpen] = useState(false); diff --git a/src/components/getting-started/GettingStarted.tsx b/src/components/getting-started/GettingStarted.tsx index f6e2be629..4e71350b4 100644 --- a/src/components/getting-started/GettingStarted.tsx +++ b/src/components/getting-started/GettingStarted.tsx @@ -10,6 +10,8 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import Step from "./Step"; import { presentArticle } from "../../utils/intercom"; import reportError from "../../utils/reportError"; @@ -25,7 +27,7 @@ import type { KYCStatus } from "../../utils/server"; function useOnboardingState() { const { address: account } = useAccount(); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: kycStatus } = useQuery({ queryKey: ["kyc", "status"] }); const isDeployed = !!bytecode; const hasKYC = Boolean( diff --git a/src/components/home/AssetList.tsx b/src/components/home/AssetList.tsx index fe8c7691e..f940c8735 100644 --- a/src/components/home/AssetList.tsx +++ b/src/components/home/AssetList.tsx @@ -6,7 +6,7 @@ import { XStack, YStack } from "tamagui"; import { parseUnits } from "viem"; -import { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadRatePreviewerSnapshot } from "@exactly/common/generated/hooks"; import { floatingDepositRates } from "@exactly/lib"; @@ -110,12 +110,14 @@ export default function AssetList() { const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); const { externalAssets } = usePortfolio(); const { data: snapshots, dataUpdatedAt } = useReadRatePreviewerSnapshot({ address: ratePreviewerAddress, + chainId: chain.id, }); const rates = snapshots ? floatingDepositRates(snapshots, Math.floor(dataUpdatedAt / 1000)) : []; diff --git a/src/components/home/CardLimits.tsx b/src/components/home/CardLimits.tsx index 6c78b5a75..1e5d681ff 100644 --- a/src/components/home/CardLimits.tsx +++ b/src/components/home/CardLimits.tsx @@ -8,7 +8,7 @@ import { XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib"; @@ -28,6 +28,7 @@ export default function CardLimits({ onPress }: { onPress: () => void }) { const { data: card } = useQuery({ queryKey: ["card", "details"] }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 095d15053..c5de12e9a 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress, exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress, exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly, @@ -64,10 +64,12 @@ export default function Home() { const { data: credential } = useQuery({ queryKey: ["credential"] }); const { data: bytecode, refetch: refetchBytecode } = useBytecode({ address: account, + chainId: chain.id, query: { enabled: !!account }, }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -88,6 +90,7 @@ export default function Home() { }); const { refetch: refetchPendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); @@ -98,6 +101,7 @@ export default function Home() { isFetching: isFetchingPreviewer, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx index e8ceff87b..eec98a5eb 100644 --- a/src/components/home/HomeActions.tsx +++ b/src/components/home/HomeActions.tsx @@ -10,7 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { useBytecode, useReadContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress } from "@exactly/common/generated/chain"; import { upgradeableModularAccountAbi, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -26,7 +26,7 @@ export default function HomeActions() { const router = useRouter(); const { address: account } = useAccount(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { t } = useTranslation(); const actions = useMemo( () => [ @@ -38,12 +38,14 @@ export default function HomeActions() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const { refetch: fetchProposals, isPending } = useReadContract({ + chainId: chain.id, functionName: "proposals", abi: [ ...upgradeableModularAccountAbi, diff --git a/src/components/home/Portfolio.tsx b/src/components/home/Portfolio.tsx index ed85231ec..eb0908a9e 100644 --- a/src/components/home/Portfolio.tsx +++ b/src/components/home/Portfolio.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack } from "tamagui"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import AssetList from "./AssetList"; @@ -33,6 +33,7 @@ export default function Portfolio() { const { refetch: refetchMarkets, isFetching: isFetchingMarkets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/home/card-upgrade/UpgradeAccount.tsx b/src/components/home/card-upgrade/UpgradeAccount.tsx index cb468a2a9..afec8ebc2 100644 --- a/src/components/home/card-upgrade/UpgradeAccount.tsx +++ b/src/components/home/card-upgrade/UpgradeAccount.tsx @@ -40,11 +40,12 @@ export default function UpgradeAccount() { const { data: installedPlugins, refetch: refetchInstalledPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, }); - const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress }); + const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress, chainId: chain.id }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const toast = useToastController(); diff --git a/src/components/loans/Amount.tsx b/src/components/loans/Amount.tsx index c2e725eea..df650882b 100644 --- a/src/components/loans/Amount.tsx +++ b/src/components/loans/Amount.tsx @@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import AmountSelector from "./AmountSelector"; @@ -34,9 +34,10 @@ export default function Amount() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!bytecode && !!address }, }); diff --git a/src/components/loans/Asset.tsx b/src/components/loans/Asset.tsx index b787f711d..b157a3531 100644 --- a/src/components/loans/Asset.tsx +++ b/src/components/loans/Asset.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, ArrowRight, Check, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { presentArticle } from "../../utils/intercom"; @@ -27,6 +27,7 @@ export default function Asset() { const [selectedMarket, setSelectedMarket] = useState(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/loans/CreditLine.tsx b/src/components/loans/CreditLine.tsx index 0b25ea605..a8bcf47e4 100644 --- a/src/components/loans/CreditLine.tsx +++ b/src/components/loans/CreditLine.tsx @@ -9,7 +9,7 @@ import { Separator, XStack, YStack } from "tamagui"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit } from "@exactly/lib"; @@ -27,9 +27,10 @@ export default function CreditLine() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!bytecode && !!address }, }); diff --git a/src/components/loans/LoanSummary.tsx b/src/components/loans/LoanSummary.tsx index bfcc474b1..5e223e88e 100644 --- a/src/components/loans/LoanSummary.tsx +++ b/src/components/loans/LoanSummary.tsx @@ -5,7 +5,7 @@ import { XStack, YStack } from "tamagui"; import { useBytecode } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; @@ -24,7 +24,11 @@ export default function LoanSummary({ loan }: { loan: Loan }) { i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address: previewerAddress, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ + address: previewerAddress, + chainId: chain.id, + query: { enabled: !!address }, + }); const { market, isFetching: isMarketFetching } = useAsset(loan.market); const symbol = market?.symbol.slice(3) === "WETH" ? "ETH" : market?.symbol.slice(3); const isBorrow = loan.installments === 1; @@ -38,6 +42,7 @@ export default function LoanSummary({ loan }: { loan: Loan }) { }); const { data: borrow, isLoading: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: loan.market && loan.amount ? [loan.market, loan.maturity ?? BigInt(defaultMaturity), loan.amount] : undefined, query: { enabled: isBorrow && !!loan.amount && !!loan.market && !!address && !!bytecode, diff --git a/src/components/loans/Loans.tsx b/src/components/loans/Loans.tsx index 469cd1a38..e5ac228a4 100644 --- a/src/components/loans/Loans.tsx +++ b/src/components/loans/Loans.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import CreditLine from "./CreditLine"; @@ -27,6 +27,7 @@ export default function Loans() { const router = useRouter(); const { refetch, isPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/loans/Review.tsx b/src/components/loans/Review.tsx index 8f49a47ff..09b6f5d56 100644 --- a/src/components/loans/Review.tsx +++ b/src/components/loans/Review.tsx @@ -72,10 +72,11 @@ export default function Review() { const singleInstallment = count === 1; const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: borrow, isPending: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, maturity ?? 0n, amount ?? 0n], query: { enabled: !!address && !!bytecode && !!maturity && !!amount && singleInstallment }, }); @@ -187,6 +188,7 @@ export default function Review() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, diff --git a/src/components/pay-mode/OverduePayments.tsx b/src/components/pay-mode/OverduePayments.tsx index f082ef42a..64ec4fe65 100644 --- a/src/components/pay-mode/OverduePayments.tsx +++ b/src/components/pay-mode/OverduePayments.tsx @@ -7,7 +7,7 @@ import { XStack, YStack } from "tamagui"; import { isBefore } from "date-fns"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import ProposalType, { decodeCrossRepayAtMaturity, @@ -27,14 +27,16 @@ export default function OverduePayments({ onSelect }: { onSelect: (maturity: big i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: pendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 }, }); diff --git a/src/components/pay-mode/Pay.tsx b/src/components/pay-mode/Pay.tsx index e7fd0ea46..75753e2b1 100644 --- a/src/components/pay-mode/Pay.tsx +++ b/src/components/pay-mode/Pay.tsx @@ -11,8 +11,8 @@ import { ScrollView, Separator, XStack, YStack } from "tamagui"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { waitForCallsStatus } from "@wagmi/core/actions"; import { digits, nonEmpty, parse, pipe, safeParse, string, transform } from "valibot"; -import { ContractFunctionExecutionError, ContractFunctionRevertedError, erc20Abi } from "viem"; -import { useBytecode, useReadContract, useSendCalls, useSimulateContract, useWriteContract } from "wagmi"; +import { ContractFunctionExecutionError, ContractFunctionRevertedError, encodeFunctionData, erc20Abi } from "viem"; +import { useBytecode, useReadContract, useSendCalls, useSimulateContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; @@ -100,9 +100,10 @@ export default function Pay() { }); const { mutateAsync: mutateSendCalls } = useSendCalls(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -132,6 +133,7 @@ export default function Pay() { const { data: fixedRepaySnapshot } = useReadContract({ address: integrationPreviewerAddress, + chainId: chain.id, abi: integrationPreviewerAbi, functionName: "fixedRepaySnapshot", args: account ? [account, marketUSDCAddress, maturity ?? 0n] : undefined, @@ -140,6 +142,7 @@ export default function Pay() { const { data: proposalDelay, isLoading: isProposalDelayLoading } = useReadProposalManagerDelay({ address: proposalManagerAddress, + chainId: chain.id, }); const simulationTimestamp = proposalDelay === undefined ? undefined : Math.floor(Date.now() / 1000) + Number(proposalDelay); @@ -163,6 +166,7 @@ export default function Pay() { const { data: balancerUSDCBalance } = useReadContract({ address: usdcAddress, + chainId: chain.id, abi: erc20Abi, functionName: "balanceOf", args: balancerVaultAddress ? [balancerVaultAddress] : undefined, @@ -282,31 +286,34 @@ export default function Pay() { const maxAmountIn = route?.fromAmount ? pad(route.fromAmount, SLIPPAGE_DIVISOR) + 69n : undefined; // HACK try to avoid ZERO_SHARES on dust deposit const { - propose: { data: repayPropose }, - executeProposal: { error: repayExecuteProposalError, isPending: isSimulatingRepay }, + request: repayPropose, + error: repayExecuteProposalError, + isPending: isSimulatingRepay, } = useSimulateProposal({ account, amount: maxRepay, market: selectedAsset.address, - enabled: enableSimulations && mode === "repay" && positionAssets > 0n, proposalType: ProposalType.RepayAtMaturity, maturity, positionAssets, + enabled: enableSimulations && mode === "repay" && positionAssets > 0n, }); const { - propose: { data: crossRepayPropose }, - executeProposal: { error: crossRepayExecuteProposalError, isPending: isSimulatingCrossRepay }, + request: crossRepayPropose, + error: crossRepayExecuteProposalError, + isPending: isSimulatingCrossRepay, } = useSimulateProposal({ account, amount: maxAmountIn, market: selectedAsset.address, - enabled: enableSimulations && mode === "crossRepay" && positionAssets > 0n, proposalType: ProposalType.CrossRepayAtMaturity, + marketOut: marketUSDCAddress, maturity, positionAssets, maxRepay, route: route?.data, + enabled: enableSimulations && mode === "crossRepay" && positionAssets > 0n && !!route, }); const { @@ -315,6 +322,7 @@ export default function Pay() { isPending: isSimulatingLegacyRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "repay", args: [maturity ?? 0n], abi: [ @@ -338,6 +346,7 @@ export default function Pay() { isPending: isSimulatingLegacyCrossRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "crossRepay", args: selectedAsset.address && maturity ? [maturity, selectedAsset.address] : undefined, abi: [ @@ -359,55 +368,73 @@ export default function Pay() { }); const { - mutate, + mutate: repay, isPending: isRepaying, isSuccess: isRepaySuccess, error: writeContractError, - } = useWriteContract({ - mutation: { - onSuccess: () => queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError), + } = useMutation({ + async mutationFn() { + if (!repayMarket) throw new Error("no repay market"); + setDisplayValues({ + amount: Number(withUSDC ? repayAssets : route?.fromAmount) / 10 ** repayMarket.decimals, + usdAmount: Number(previewValueUSD) / 1e18, + }); + const call = (() => { + switch (mode) { + case "repay": + if (!repayPropose) throw new Error("no repay simulation"); + return { + to: repayPropose.address, + data: encodeFunctionData(repayPropose), + }; + + case "legacyRepay": { + if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); + const { address, abi, functionName, args } = legacyRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + case "crossRepay": + if (!crossRepayPropose) throw new Error("no cross repay simulation"); + return { + to: crossRepayPropose.address, + data: encodeFunctionData(crossRepayPropose), + }; + + case "legacyCrossRepay": { + if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); + const { address, abi, functionName, args } = legacyCrossRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + default: + throw new Error("unexpected mode"); + } + })(); + const { id } = await mutateSendCalls({ + calls: [call], + capabilities: { + paymasterService: { + url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, + context: { policyId: alchemyGasPolicyId }, + }, + }, + }); + const { status } = await waitForCallsStatus(exa, { id }); + if (status === "failure") throw new Error("failed to repay"); + }, + onMutate() { + setEnableSimulations(false); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); + }, + onSettled() { + setEnableSimulations(true); + }, + onError(error) { + reportError(error); }, }); - const handlePayment = useCallback(() => { - if (!repayMarket) return; - setDisplayValues({ - amount: Number(withUSDC ? repayAssets : route?.fromAmount) / 10 ** repayMarket.decimals, - usdAmount: Number(previewValueUSD) / 1e18, - }); - switch (mode) { - case "repay": - if (!repayPropose) throw new Error("no repay simulation"); - mutate(repayPropose.request); - break; - case "legacyRepay": - if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); - mutate(legacyRepaySimulation.request); - break; - case "crossRepay": - if (!crossRepayPropose) throw new Error("no cross repay simulation"); - mutate(crossRepayPropose.request); - break; - case "legacyCrossRepay": - if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); - mutate(legacyCrossRepaySimulation.request); - break; - } - setEnableSimulations(false); - }, [ - crossRepayPropose, - legacyCrossRepaySimulation, - legacyRepaySimulation, - mode, - previewValueUSD, - repayAssets, - repayMarket, - repayPropose, - route?.fromAmount, - withUSDC, - mutate, - ]); - const { mutateAsync: repayWithExternalAsset, isPending: isExternalRepaying, @@ -457,10 +484,15 @@ export default function Pay() { }, }, }); - setEnableSimulations(false); const { status } = await waitForCallsStatus(exa, { id }); if (status === "failure") throw new Error("failed to repay with external asset"); }, + onMutate() { + setEnableSimulations(false); + }, + onSettled() { + setEnableSimulations(true); + }, onError(error) { reportError(error); }, @@ -796,7 +828,7 @@ export default function Pay() { primary loading={loading && positionAssets > 0n} disabled={disabled} - onPress={selectedAsset.external ? () => repayWithExternalAsset() : handlePayment} + onPress={selectedAsset.external ? () => repayWithExternalAsset() : () => repay()} > {handleButtonText()} diff --git a/src/components/pay-mode/PayMode.tsx b/src/components/pay-mode/PayMode.tsx index 0bb9093b9..81a4183b0 100644 --- a/src/components/pay-mode/PayMode.tsx +++ b/src/components/pay-mode/PayMode.tsx @@ -6,7 +6,7 @@ import { useRouter } from "expo-router"; import { ScrollView, XStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import OverduePayments from "./OverduePayments"; @@ -28,6 +28,7 @@ export default function PayMode() { const router = useRouter(); const { refetch, isPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/pay-mode/PaySelector.tsx b/src/components/pay-mode/PaySelector.tsx index 1c974f61f..4d4d358b5 100644 --- a/src/components/pay-mode/PaySelector.tsx +++ b/src/components/pay-mode/PaySelector.tsx @@ -9,7 +9,7 @@ import { XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; import { formatUnits, parseUnits } from "viem"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib"; @@ -41,6 +41,7 @@ export default function PaySelector() { const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); @@ -279,6 +280,7 @@ function InstallmentButton({ }); const { data: borrowPreview, isLoading: isBorrowPreviewLoading } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: market && account && firstMaturity && calculationAssets ? [market.market, BigInt(firstMaturity), calculationAssets] diff --git a/src/components/pay-mode/RepayAmountSelector.tsx b/src/components/pay-mode/RepayAmountSelector.tsx index 90fabd4e7..00ffb502d 100644 --- a/src/components/pay-mode/RepayAmountSelector.tsx +++ b/src/components/pay-mode/RepayAmountSelector.tsx @@ -146,7 +146,9 @@ export default function RepayAmountSelector({ onChangeText={handleAmountChange} onFocus={() => setFocused(true)} placeholder="0" - style={{ fontSize: 34, fontWeight: 400, letterSpacing: -0.2 }} + fontSize={34} + fontWeight="400" + letterSpacing={-0.2} textAlign="center" value={displayValue} /> diff --git a/src/components/pay-mode/UpcomingPayments.tsx b/src/components/pay-mode/UpcomingPayments.tsx index a3824f8db..439149f02 100644 --- a/src/components/pay-mode/UpcomingPayments.tsx +++ b/src/components/pay-mode/UpcomingPayments.tsx @@ -7,7 +7,7 @@ import { XStack, YStack } from "tamagui"; import { isBefore } from "date-fns"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import ProposalType, { decodeCrossRepayAtMaturity, @@ -27,14 +27,16 @@ export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bi i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: pendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 }, }); diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index d8cb7290a..d83af7722 100644 --- a/src/components/roll-debt/RollDebt.tsx +++ b/src/components/roll-debt/RollDebt.tsx @@ -10,14 +10,13 @@ import { useToastController } from "@tamagui/toast"; import { ScrollView, Separator, Spinner, XStack, YStack } from "tamagui"; import { nonEmpty, pipe, safeParse, string } from "valibot"; -import { ContractFunctionExecutionError, encodeAbiParameters } from "viem"; -import { useBytecode, useWriteContract } from "wagmi"; +import { ContractFunctionExecutionError } from "viem"; +import { useWriteContract } from "wagmi"; -import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerPreviewBorrowAtMaturity, - useSimulateExaPluginPropose, } from "@exactly/common/generated/hooks"; import ProposalType from "@exactly/common/ProposalType"; import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; @@ -28,6 +27,7 @@ import View from "../../components/shared/View"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useAsset from "../../utils/useAsset"; +import useSimulateProposal from "../../utils/useSimulateProposal"; import Button from "../shared/Button"; import Skeleton from "../shared/Skeleton"; @@ -51,12 +51,11 @@ export default function Pay() { const borrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(success ? repayMaturity : 0)); const rolloverMaturityBorrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(borrowMaturity)); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); - const { data: borrowPreview } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, BigInt(borrowMaturity), borrow?.previewValue ?? 0n], - query: { enabled: !!bytecode && !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, + query: { enabled: !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, }); if (!success || !exaUSDC || !borrow) return null; @@ -228,35 +227,22 @@ function RolloverButton({ const { t } = useTranslation(); const { address } = useAccount(); const router = useRouter(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); const toast = useToastController(); const slippage = (WAD * 105n) / 100n; const maxRepayAssets = (borrow.previewValue * slippage) / WAD; const percentage = WAD; - const { data: proposeSimulation } = useSimulateExaPluginPropose({ - address, - args: [ - marketUSDCAddress, - maxRepayAssets, - ProposalType.RollDebt, - encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "repayMaturity", type: "uint256" }, - { name: "borrowMaturity", type: "uint256" }, - { name: "maxRepayAssets", type: "uint256" }, - { name: "percentage", type: "uint256" }, - ], - }, - ], - [{ repayMaturity, borrowMaturity, maxRepayAssets, percentage }], - ), - ], - query: { enabled: !!address && !!bytecode }, + const { request: proposeSimulation, error: executeProposalError } = useSimulateProposal({ + account: address, + amount: maxRepayAssets, + market: marketUSDCAddress, + proposalType: ProposalType.RollDebt, + borrowMaturity, + maxRepayAssets, + percentage, + repayMaturity, + enabled: !!address, }); const { @@ -266,7 +252,7 @@ function RolloverButton({ } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, args: address ? [address] : undefined, - query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, + query: { enabled: !!address, gcTime: 0, refetchInterval: 30_000 }, }); const { @@ -281,7 +267,7 @@ function RolloverButton({ duration: 1000, burntOptions: { haptic: "success", preset: "done" }, }); - if (address && bytecode) refetchPendingProposals().catch(reportError); + if (address) refetchPendingProposals().catch(reportError); router.dismissTo("/activity"); }, onError: (error) => { @@ -298,7 +284,7 @@ function RolloverButton({ const proposeRollDebt = useCallback(() => { if (!address) throw new Error("no address"); if (!proposeSimulation) throw new Error("no propose roll debt simulation"); - mutate(proposeSimulation.request); + mutate(proposeSimulation); }, [address, proposeSimulation, mutate]); const hasProposed = pendingProposals?.some( @@ -316,10 +302,15 @@ function RolloverButton({ ); const disabled = - !!isError || isProposeRollDebtPending || isPendingProposalsPending || !proposeSimulation || hasProposed; + !!isError || + !!executeProposalError || + isProposeRollDebtPending || + isPendingProposalsPending || + !proposeSimulation || + hasProposed; return ( + )} + { + queryClient.invalidateQueries({ queryKey: ["swap"] }).catch(reportError); + onClose(); }} - stickyHeaderHiddenOnScroll > - - - - - - - - - - - - - }} - /> - - - {`$${fromUsdAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {Number(formatUnits(fromAmount, fromToken.decimals)).toFixed(8)} - - - - - {`$${toUsdAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {Number(formatUnits(toAmount, toToken.decimals)).toFixed(8)} - - - - - - - - - - { - queryClient.invalidateQueries({ queryKey: ["swap"] }).catch(reportError); - router.dismissTo("/activity"); - }} - > - - {t("Close")} - - - - - - + + {t("Close")} + + + ); diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index 15e053695..d02cb7499 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -13,7 +13,7 @@ import { parse } from "valibot"; import { formatUnits, parseUnits, zeroAddress } from "viem"; import { useSimulateContract, useWriteContract } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { auditorAbi, marketAbi, @@ -79,6 +79,7 @@ export default function Swaps() { const [activeInput, setActiveInput] = useState<"from" | "to">("from"); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); @@ -111,7 +112,7 @@ export default function Swaps() { (token: undefined | { external: boolean; token: Token }) => { if (!token) return; if (token.external) return parse(Address, token.token.address); - return protocolAssets.find((a) => a.asset === token.token.address)?.market ?? zeroAddress; + return protocolAssets.find((a) => a.asset === token.token.address)?.market; }, [protocolAssets], ); @@ -253,8 +254,9 @@ export default function Swaps() { }, [activeInput, route]); const { - propose: { data: swapPropose }, - executeProposal: { error: swapExecuteProposalError, isPending: isSimulatingSwap }, + request: swapPropose, + error: swapExecuteProposalError, + isPending: isSimulatingSwap, } = useSimulateProposal({ account, amount: activeInput === "from" ? fromAmount : (fromAmount * (WAD * (1000n + SLIPPAGE_PERCENT))) / 1000n / WAD, @@ -283,6 +285,7 @@ export default function Swaps() { isPending: isSimulatingExternalSwap, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "swap", args: [ parse(Address, fromToken?.token.address ?? zeroAddress), @@ -345,7 +348,7 @@ export default function Swaps() { if (fromToken?.external && externalSwap) { mutate(externalSwap.request); } else if (swapPropose) { - mutate(swapPropose.request); + mutate(swapPropose); } updateSwap((old) => ({ ...old, enableSimulations: false })); }, [route, fromToken?.external, externalSwap, swapPropose, mutate]); @@ -457,7 +460,7 @@ export default function Swaps() { - + {(caution || danger) && showWarning && ( @@ -587,6 +590,7 @@ export default function Swaps() { return ( { onClose(); }} diff --git a/src/utils/accountClient.ts b/src/utils/accountClient.ts index fcec943b8..2193f23ed 100644 --- a/src/utils/accountClient.ts +++ b/src/utils/accountClient.ts @@ -25,7 +25,14 @@ import { bufferToBase64URLString, type AuthenticatorAssertionResponseJSON, } from "@simplewebauthn/browser"; -import { getCallsStatus, getConnection, sendCalls, sendTransaction, signMessage } from "@wagmi/core/actions"; +import { + getCallsStatus, + getConnection, + sendCalls, + sendTransaction, + signMessage, + switchChain, +} from "@wagmi/core/actions"; import { bytesToBigInt, bytesToHex, @@ -33,6 +40,7 @@ import { concatHex, custom, encodeAbiParameters, + encodeFunctionData, encodePacked, ethAddress, hashMessage, @@ -61,7 +69,7 @@ import e2e from "./e2e"; import { login } from "./onesignal"; import publicClient from "./publicClient"; import queryClient, { type AuthMethod } from "./queryClient"; -import { isPasskeyCancelled } from "./reportError"; +import reportError, { isPasskeyCancelled } from "./reportError"; import ownerConfig from "./wagmi/owner"; import type { Credential } from "@exactly/common/validation"; @@ -154,7 +162,12 @@ export default async function createAccountClient({ credentialId, factory, x, y switch (method) { case "wallet_sendCalls": { if (!Array.isArray(params) || params.length !== 1) throw new Error("bad params"); - const { calls, from, id } = params[0] as { calls: readonly Call[]; from?: Address; id?: string }; + const { calls, chainId, from, id } = params[0] as { + calls: readonly Call[]; + chainId?: Hex; + from?: Address; + id?: string; + }; if (from && from !== accountAddress) throw new Error("bad account"); if (queryClient.getQueryData(["method"]) === "webauthn") { const { hash } = await client.sendUserOperation({ @@ -171,6 +184,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { return await sendCalls(ownerConfig, { id, + chainId: chainId ? hexToNumber(chainId) : chain.id, calls: [execute], capabilities: { paymasterService: { @@ -180,10 +194,24 @@ export default async function createAccountClient({ credentialId, factory, x, y }, }, }); - } catch { + } catch (error) { + reportError(error, { + level: "warning", + extra: error instanceof Error ? { cause: error.cause } : undefined, + }); // TODO filter errors - const hash = await sendTransaction(ownerConfig, execute); - return { id: concat([hash, numberToHex(chain.id, { size: 32 }), TX_MAGIC_ID]) }; + const requestedChainId = chainId ? hexToNumber(chainId) : chain.id; + await switchChain(ownerConfig, { chainId: requestedChainId }); + try { + const hash = await sendTransaction(ownerConfig, { + to: accountAddress, + data: encodeFunctionData(execute), + chainId: requestedChainId, + }); + return { id: concat([hash, numberToHex(requestedChainId, { size: 32 }), TX_MAGIC_ID]) }; + } finally { + await switchChain(ownerConfig, { chainId: chain.id }).catch(reportError); + } } } case "wallet_getCallsStatus": { @@ -208,6 +236,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { const { to, data = "0x", value = 0n } = params[0] as TransactionRequest; const { id } = await sendCalls(ownerConfig, { + chainId: chain.id, calls: [ { to: accountAddress, @@ -225,8 +254,8 @@ export default async function createAccountClient({ credentialId, factory, x, y }, }); return id; - } catch { - // TODO filter errors + } catch (error) { + reportError(error, { level: "warning" }); return client.request({ method: method as never, params: params as never }); } } diff --git a/src/utils/reportError.ts b/src/utils/reportError.ts index 71a06d7a2..e45058c17 100644 --- a/src/utils/reportError.ts +++ b/src/utils/reportError.ts @@ -3,6 +3,17 @@ import { BaseError, ContractFunctionRevertedError } from "viem"; import revertReason from "@exactly/common/revertReason"; +const bigintReplacer = (_: string, v: unknown) => (typeof v === "bigint" ? `0x${v.toString(16)}` : v); + +(globalThis as Record).__captureProviderError__ = (method: string, data: unknown) => + withScope((scope) => { + scope.setTag("provider.method", method); + scope.setLevel("warning"); + captureException(new Error(`[farcaster-provider] ${method}`), { + extra: { providerError: JSON.stringify(data, bigintReplacer) }, + }); + }); + export default function reportError(error: unknown, hint?: Parameters[1]) { console.error(error); // eslint-disable-line no-console const parsed = parseError(error); diff --git a/src/utils/useAsset.ts b/src/utils/useAsset.ts index 8691c8d5f..1ea5190c3 100644 --- a/src/utils/useAsset.ts +++ b/src/utils/useAsset.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit, withdrawLimit } from "@exactly/lib"; @@ -19,6 +19,7 @@ export default function useAsset(address?: Address) { isFetching: isMarketsFetching, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/utils/useAuth.ts b/src/utils/useAuth.ts index ff5baaf9f..a2607bd7a 100644 --- a/src/utils/useAuth.ts +++ b/src/utils/useAuth.ts @@ -27,8 +27,11 @@ export default function useAuth(onDomainError: () => void, onSuccess?: (credenti const { mutate: signIn, ...mutation } = useMutation({ mutationFn: async ({ method, register }: { method: AuthMethod; register?: boolean }) => { queryClient.setQueryData(["method"], chain.id === base.id ? "siwe" : method); - if (method === "siwe" && getConnection(ownerConfig).isDisconnected) { - await connectOwner({ connector: await getOwnerConnector() }); + if (method === "siwe") { + const connection = getConnection(ownerConfig); + if (connection.isDisconnected || !connection.address) { + await connectOwner({ connector: await getOwnerConnector() }); + } } const credential = method === "siwe" || !register ? await getCredential() : await createCredential(); queryClient.setQueryData(["credential"], credential); diff --git a/src/utils/usePendingOperations.ts b/src/utils/usePendingOperations.ts index b76fe2f6e..aea569c63 100644 --- a/src/utils/usePendingOperations.ts +++ b/src/utils/usePendingOperations.ts @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useMutationState } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals } from "@exactly/common/generated/hooks"; import useAccount from "./useAccount"; @@ -14,10 +14,11 @@ import type { MutationState } from "@tanstack/react-query"; export default function usePendingOperations() { const { address: exaAccount } = useAccount({ config: exa }); - const { data: bytecode } = useBytecode({ address: exaAccount, query: { enabled: !!exaAccount } }); + const { data: bytecode } = useBytecode({ address: exaAccount, chainId: chain.id, query: { enabled: !!exaAccount } }); const proposals = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: exaAccount ? [exaAccount] : undefined, query: { enabled: !!exaAccount && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); diff --git a/src/utils/usePortfolio.ts b/src/utils/usePortfolio.ts index 445a47c3e..558777dec 100644 --- a/src/utils/usePortfolio.ts +++ b/src/utils/usePortfolio.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadRatePreviewerSnapshot } from "@exactly/common/generated/hooks"; import { floatingDepositRates, withdrawLimit } from "@exactly/lib"; @@ -43,9 +43,11 @@ export default function usePortfolio(account_?: Hex, options?: { sortBy?: "usdcF const { data: rateSnapshot, dataUpdatedAt: rateDataUpdatedAt } = useReadRatePreviewerSnapshot({ address: ratePreviewerAddress, + chainId: chain.id, }); const { data: markets, isPending: isMarketsPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/utils/useSimulateProposal.ts b/src/utils/useSimulateProposal.ts index af33a18c0..8d37833ff 100644 --- a/src/utils/useSimulateProposal.ts +++ b/src/utils/useSimulateProposal.ts @@ -1,47 +1,35 @@ -import { useMemo } from "react"; +import { encodeAbiParameters, encodeFunctionData, multicall3Abi, zeroAddress, type Address, type Hex } from "viem"; +import { useReadContracts, type UseWriteContractReturnType } from "wagmi"; -import { - bytesToHex, - encodeAbiParameters, - hexToBigInt, - hexToBytes, - keccak256, - toBytes, - zeroAddress, - type Address, - type BlockOverrides, - type Hex, - type StateOverride, -} from "viem"; -import { useBytecode, useSimulateContract } from "wagmi"; - -import { - exaPluginAddress, - exaPreviewerAddress, - proposalManagerAddress, - swapperAddress, -} from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, proposalManagerAddress } from "@exactly/common/generated/chain"; import { auditorAbi, exaPluginAbi, + exaPreviewerAbi, marketAbi, proposalManagerAbi, upgradeableModularAccountAbi, - useReadExaPreviewerAssets, - useReadProposalManagerDelay, - useReadProposalManagerQueueNonces, + useReadExaPluginPluginMetadata, } from "@exactly/common/generated/hooks"; import ProposalType from "@exactly/common/ProposalType"; +import useSimulateBlocks from "./wagmi/useSimulateBlocks"; + export default function useSimulateProposal({ account, amount, - market, + chain: { + contracts: { + multicall3: { address: multicall3Address }, + }, + } = chain, enabled = true, + market, ...proposal }: { account: Address | undefined; amount: bigint | undefined; + chain?: { contracts: { multicall3: { address: Address } } }; enabled?: boolean; market: Address | undefined; } & ( @@ -59,12 +47,7 @@ export default function useSimulateProposal({ repayMaturity: bigint | undefined; } | { - maturity: bigint | undefined; - maxAssets: bigint | undefined; - proposalType: typeof ProposalType.BorrowAtMaturity; - receiver: Address | undefined; - } - | { + marketOut: Address | undefined; maturity: bigint | undefined; maxRepay: bigint | undefined; positionAssets: bigint | undefined; @@ -73,246 +56,353 @@ export default function useSimulateProposal({ } | { maturity: bigint | undefined; - positionAssets: bigint | undefined; - proposalType: typeof ProposalType.RepayAtMaturity; - } - | { - proposalType: typeof ProposalType.Redeem; + maxAssets: bigint | undefined; + proposalType: typeof ProposalType.BorrowAtMaturity; receiver: Address | undefined; } | { - proposalType: typeof ProposalType.Withdraw; - receiver: Address | undefined; + maturity: bigint | undefined; + positionAssets: bigint | undefined; + proposalType: typeof ProposalType.RepayAtMaturity; } + | { proposalType: typeof ProposalType.Redeem; receiver: Address | undefined } + | { proposalType: typeof ProposalType.Withdraw; receiver: Address | undefined } )) { + const { data: reads } = useReadContracts({ + contracts: [ + { address: account, abi: upgradeableModularAccountAbi, functionName: "getInstalledPlugins" }, + { address: proposalManagerAddress, abi: proposalManagerAbi, functionName: "delay" }, + { + address: proposalManagerAddress, + abi: proposalManagerAbi, + functionName: "queueNonces", + args: account ? [account] : undefined, + }, + { address: multicall3Address, abi: multicall3Abi, functionName: "getCurrentBlockTimestamp" }, + { + address: multicall3Address, + abi: [ + { + type: "function", + name: "getBlockNumber", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + ], + functionName: "getBlockNumber", + }, + { address: exaPreviewerAddress, abi: exaPreviewerAbi, functionName: "assets" }, + ] as const, + allowFailure: true, + query: { enabled: enabled && !!account }, + }); + const [plugins, delay, nonce, timestamp, blockNumber, assets] = reads ?? []; + const installedPlugins = plugins?.status === "success" ? plugins.result : undefined; + const { data: pluginMetadata } = useReadExaPluginPluginMetadata({ + address: installedPlugins?.[0], + query: { enabled: !!installedPlugins?.[0] }, + }); const proposalData = proposal.proposalType === ProposalType.BorrowAtMaturity - ? proposal.maturity === undefined || proposal.maxAssets === undefined || proposal.receiver === undefined - ? undefined - : encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "maturity", type: "uint256" }, - { name: "maxAssets", type: "uint256" }, - { name: "receiver", type: "address" }, - ], - }, - ], - [{ maturity: proposal.maturity, maxAssets: proposal.maxAssets, receiver: proposal.receiver }], - ) + ? encodeBorrowAtMaturity(proposal) : proposal.proposalType === ProposalType.CrossRepayAtMaturity - ? proposal.maturity === undefined || - proposal.positionAssets === undefined || - proposal.maxRepay === undefined || - proposal.route === undefined + ? pluginMetadata?.version === undefined ? undefined - : encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "maturity", type: "uint256" }, - { name: "positionAssets", type: "uint256" }, - { name: "maxRepay", type: "uint256" }, - { name: "route", type: "bytes" }, - ], - }, - ], - [ - { - maturity: proposal.maturity, - positionAssets: proposal.positionAssets, - maxRepay: proposal.maxRepay, - route: proposal.route, - }, - ], - ) + : encodeCrossRepayAtMaturity({ + ...proposal, + marketOut: pluginMetadata.version >= "1.1.0" ? proposal.marketOut : undefined, + }) : proposal.proposalType === ProposalType.RepayAtMaturity - ? proposal.maturity === undefined || proposal.positionAssets === undefined - ? undefined - : encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "maturity", type: "uint256" }, - { name: "positionAssets", type: "uint256" }, - ], - }, - ], - [{ maturity: proposal.maturity, positionAssets: proposal.positionAssets }], - ) + ? encodeRepayAtMaturity(proposal) : proposal.proposalType === ProposalType.RollDebt - ? proposal.repayMaturity === undefined || - proposal.borrowMaturity === undefined || - proposal.maxRepayAssets === undefined || - proposal.percentage === undefined - ? undefined - : encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "repayMaturity", type: "uint256" }, - { name: "borrowMaturity", type: "uint256" }, - { name: "maxRepayAssets", type: "uint256" }, - { name: "percentage", type: "uint256" }, - ], - }, - ], - [ - { - repayMaturity: proposal.repayMaturity, - borrowMaturity: proposal.borrowMaturity, - maxRepayAssets: proposal.maxRepayAssets, - percentage: proposal.percentage, - }, - ], - ) + ? encodeRollDebt(proposal) : proposal.proposalType === ProposalType.Swap - ? proposal.assetOut === undefined || proposal.minAmountOut === undefined || proposal.route === undefined - ? undefined - : encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "assetOut", type: "address" }, - { name: "minAmountOut", type: "uint256" }, - { name: "route", type: "bytes" }, - ], - }, - ], - [{ assetOut: proposal.assetOut, minAmountOut: proposal.minAmountOut, route: proposal.route }], - ) - : proposal.receiver && encodeAbiParameters([{ type: "address" }], [proposal.receiver]); - const { data: deployed } = useBytecode({ address: account, query: { enabled: enabled && !!account } }); - const hasMarket = market !== undefined && market !== zeroAddress; - const propose = useSimulateContract({ - account, - address: account, - functionName: "propose", - abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi], - args: hasMarket ? [market, amount ?? 0n, proposal.proposalType, proposalData ?? "0x"] : undefined, - query: { enabled: enabled && !!deployed && !!account && !!amount && hasMarket }, - }); - - const { data: proposalDelay } = useReadProposalManagerDelay({ address: proposalManagerAddress, query: { enabled } }); - const { data: assets } = useReadExaPreviewerAssets({ address: exaPreviewerAddress, query: { enabled } }); - const { data: nonce } = useReadProposalManagerQueueNonces({ - address: proposalManagerAddress, - args: account ? [account] : undefined, - query: { enabled: enabled && !!account }, + ? encodeSwap(proposal) + : encodeAddress(proposal.receiver); + const legacyAsset = + market === undefined || assets?.status !== "success" + ? undefined + : assets.result.find(({ market: assetMarket }) => assetMarket === market)?.asset; + const request = + account === undefined + ? undefined + : proposal.proposalType === ProposalType.Withdraw && + pluginMetadata?.version !== undefined && + pluginMetadata.version < "0.0.4" + ? legacyAsset === undefined || + legacyAsset === zeroAddress || + amount === undefined || + proposal.receiver === undefined + ? undefined + : { + account, + address: account, + abi: legacyProposeAbi, + functionName: "propose" as const, + args: [legacyAsset, amount, proposal.receiver] as const, + } + : market === undefined || + market === zeroAddress || + amount === undefined || + proposalData === undefined || + (proposal.proposalType === ProposalType.Withdraw && pluginMetadata?.version === undefined) + ? undefined + : { + account, + address: account, + abi: proposeAbi, + functionName: "propose" as const, + args: [market, amount, proposal.proposalType, proposalData] as const, + }; + const executeRequest = + account === undefined || nonce?.status !== "success" + ? undefined + : { + account, + address: account, + abi: executeProposalAbi, + functionName: "executeProposal" as const, + args: [nonce.result] as const, + }; + const simulation = useSimulateBlocks({ + blockNumber: blockNumber?.status === "success" ? blockNumber.result : undefined, + blocks: [ + { + blockOverrides: timestamp?.status === "success" ? { time: timestamp.result } : undefined, + calls: request + ? [ + { + account: request.account, + to: request.address, + data: encodeFunctionData({ abi: request.abi, functionName: request.functionName, args: request.args }), + }, + ] + : [], + }, + { + blockOverrides: + timestamp?.status === "success" && delay?.status === "success" + ? { time: timestamp.result + delay.result } + : undefined, + calls: executeRequest + ? [ + { + account: executeRequest.account, + to: executeRequest.address, + data: encodeFunctionData({ + abi: executeRequest.abi, + functionName: executeRequest.functionName, + args: executeRequest.args, + }), + }, + ] + : [], + }, + ], + chainId: chain.id, + query: { + enabled: + enabled && + installedPlugins !== undefined && + request !== undefined && + executeRequest !== undefined && + delay?.status === "success" && + timestamp?.status === "success" && + blockNumber?.status === "success", + }, }); + const propose = simulation.data?.[0]?.calls[0]; + const execute = simulation.data?.[1]?.calls[0]; + return { + request: + propose?.status === "success" && execute?.status === "success" + ? (request as Parameters[0]) + : undefined, + isPending: simulation.isPending, + error: + simulation.error ?? + (propose?.status === "failure" ? propose.error : null) ?? + (execute?.status === "failure" ? execute.error : null), + }; +} - const stateOverride = useMemo(() => { - if ( - account === undefined || - amount === undefined || - !hasMarket || - assets === undefined || - nonce === undefined || - proposalData === undefined - ) { - return; - } - const proposalsSlot = hexToBigInt( - keccak256( - encodeAbiParameters( - [{ type: "uint256" }, { type: "bytes32" }], - [nonce, keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 5n]))], - ), - ), - ); - const proposalDataSlot = hexToBigInt(keccak256(encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 4n]))); - const proposalDataBytes = hexToBytes(proposalData); - return [ - { - address: proposalManagerAddress, - state: [ - { - // nonces[account] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 3n])), - value: encodeAbiParameters([{ type: "uint256" }], [nonce]), - }, +function encodeBorrowAtMaturity({ + maturity, + maxAssets, + receiver, +}: { + maturity: bigint | undefined; + maxAssets: bigint | undefined; + receiver: Address | undefined; +}) { + return maturity === undefined || maxAssets === undefined || receiver === undefined + ? undefined + : encodeAbiParameters( + [ { - // queueNonces[account] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 4n])), - value: encodeAbiParameters([{ type: "uint256" }], [nonce + 1n]), + type: "tuple", + components: [ + { name: "maturity", type: "uint256" }, + { name: "maxAssets", type: "uint256" }, + { name: "receiver", type: "address" }, + ], }, + ], + [{ maturity, maxAssets, receiver }], + ); +} + +function encodeCrossRepayAtMaturity({ + marketOut, + maturity, + maxRepay, + positionAssets, + route, +}: { + marketOut?: Address; + maturity: bigint | undefined; + maxRepay: bigint | undefined; + positionAssets: bigint | undefined; + route: Hex | undefined; +}) { + if (maturity === undefined || positionAssets === undefined || maxRepay === undefined || route === undefined) return; + return marketOut === undefined + ? encodeAbiParameters( + [ { - // proposals[account][nonce][0] (amount) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot]), - value: encodeAbiParameters([{ type: "uint256" }], [amount]), + type: "tuple", + components: [ + { name: "maturity", type: "uint256" }, + { name: "positionAssets", type: "uint256" }, + { name: "maxRepay", type: "uint256" }, + { name: "route", type: "bytes" }, + ], }, + ], + [{ maturity, positionAssets, maxRepay, route }], + ) + : encodeAbiParameters( + [ { - // proposals[account][nonce][1] (market) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 1n]), - value: encodeAbiParameters([{ type: "address" }], [market]), + type: "tuple", + components: [ + { name: "maturity", type: "uint256" }, + { name: "positionAssets", type: "uint256" }, + { name: "marketOut", type: "address" }, + { name: "maxRepay", type: "uint256" }, + { name: "route", type: "bytes" }, + ], }, + ], + [{ maturity, positionAssets, marketOut, maxRepay, route }], + ); +} + +function encodeRepayAtMaturity({ + maturity, + positionAssets, +}: { + maturity: bigint | undefined; + positionAssets: bigint | undefined; +}) { + return maturity === undefined || positionAssets === undefined + ? undefined + : encodeAbiParameters( + [ { - // proposals[account][nonce][3] (proposalType) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 3n]), - value: encodeAbiParameters([{ type: "uint8" }], [proposal.proposalType]), + type: "tuple", + components: [ + { name: "maturity", type: "uint256" }, + { name: "positionAssets", type: "uint256" }, + ], }, + ], + [{ maturity, positionAssets }], + ); +} + +function encodeRollDebt({ + borrowMaturity, + maxRepayAssets, + percentage, + repayMaturity, +}: { + borrowMaturity: bigint | undefined; + maxRepayAssets: bigint | undefined; + percentage: bigint | undefined; + repayMaturity: bigint | undefined; +}) { + return repayMaturity === undefined || + borrowMaturity === undefined || + maxRepayAssets === undefined || + percentage === undefined + ? undefined + : encodeAbiParameters( + [ { - // proposals[account][nonce][4] (2 * proposalData.length + 1) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 4n]), - value: encodeAbiParameters([{ type: "uint256" }], [BigInt(2 * proposalDataBytes.length + 1)]), + type: "tuple", + components: [ + { name: "repayMaturity", type: "uint256" }, + { name: "borrowMaturity", type: "uint256" }, + { name: "maxRepayAssets", type: "uint256" }, + { name: "percentage", type: "uint256" }, + ], }, - ...Array.from({ length: Math.ceil(proposalDataBytes.length / 32) }, (_, index) => ({ - // keccak256(proposalData.slot) (proposalData) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalDataSlot + BigInt(index)]), - value: encodeAbiParameters( - [{ type: "bytes32" }], - [bytesToHex(proposalDataBytes.slice(index * 32, (index + 1) * 32))], - ), - })), + ], + [{ repayMaturity, borrowMaturity, maxRepayAssets, percentage }], + ); +} + +function encodeSwap({ + assetOut, + minAmountOut, + route, +}: { + assetOut: Address | undefined; + minAmountOut: bigint | undefined; + route: Hex | undefined; +}) { + return assetOut === undefined || minAmountOut === undefined || route === undefined + ? undefined + : encodeAbiParameters( + [ { - // hasRole(PROPOSER_ROLE, exaPlugin) - slot: keccak256( - encodeAbiParameters( - [{ type: "address" }, { type: "bytes32" }], - [ - exaPluginAddress, - keccak256( - encodeAbiParameters( - [{ type: "bytes32" }, { type: "uint256" }], - [keccak256(toBytes("PROPOSER_ROLE")), 0n], - ), - ), - ], - ), - ), - value: encodeAbiParameters([{ type: "bool" }], [true]), + type: "tuple", + components: [ + { name: "assetOut", type: "address" }, + { name: "minAmountOut", type: "uint256" }, + { name: "route", type: "bytes" }, + ], }, - ...[swapperAddress, ...assets.map(({ asset }) => asset)].map((target) => ({ - // allowlist[target] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [target, 2n])), - value: encodeAbiParameters([{ type: "bool" }], [true]), - })), ], - }, - ] satisfies StateOverride; - }, [account, amount, assets, hasMarket, market, nonce, proposal.proposalType, proposalData]); - const blockOverrides = - proposalDelay === undefined - ? undefined - : ({ time: BigInt(Math.floor(Date.now() / 1000)) + proposalDelay } satisfies BlockOverrides); - const executeProposal = useSimulateContract({ - account, - address: account, - functionName: "executeProposal", - args: [nonce ?? 0n], - abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi, ...auditorAbi, ...marketAbi], - stateOverride, - blockOverrides, - query: { - enabled: enabled && !!deployed && nonce !== undefined && !!account && !!stateOverride && !!blockOverrides, - }, - }); + [{ assetOut, minAmountOut, route }], + ); +} - return { propose, executeProposal, proposalData }; +function encodeAddress(receiver: Address | undefined) { + return receiver && encodeAbiParameters([{ type: "address" }], [receiver]); } + +const proposeAbi = [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi]; +const legacyProposeAbi = [ + ...upgradeableModularAccountAbi, + { + type: "function", + name: "propose", + inputs: [ + { internalType: "contract IMarket", name: "market", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "receiver", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; +const executeProposalAbi = [ + ...upgradeableModularAccountAbi, + ...exaPluginAbi, + ...proposalManagerAbi, + ...auditorAbi, + ...marketAbi, +]; diff --git a/src/utils/wagmi/useSimulateBlocks.ts b/src/utils/wagmi/useSimulateBlocks.ts new file mode 100644 index 000000000..afe4b021f --- /dev/null +++ b/src/utils/wagmi/useSimulateBlocks.ts @@ -0,0 +1,34 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { + simulateBlocks, + type SimulateBlocksErrorType, + type SimulateBlocksParameters, + type SimulateBlocksReturnType, +} from "viem/actions"; +import { useChainId, useConfig, type Config } from "wagmi"; +import { hashFn, structuralSharing } from "wagmi/query"; + +// TODO remove after https://github.com/wevm/wagmi/pull/4993 +export default function useSimulateBlocks({ + config: configParameter, + chainId: chainIdParameter, + query, + ...parameters +}: SimulateBlocksParameters & { chainId?: number; config?: Config; query?: QueryOptions }) { + const config = useConfig({ config: configParameter }); + const chainId = useChainId({ config }); + const resolvedChainId = chainIdParameter ?? chainId; + return useQuery({ + ...query, + queryKey: ["simulateBlocks", { chainId: resolvedChainId, ...parameters }], + queryKeyHashFn: hashFn, + structuralSharing, + enabled: query?.enabled ?? true, + queryFn: () => simulateBlocks(config.getClient({ chainId: resolvedChainId }), parameters), + }); +} + +type QueryOptions = Omit< + UseQueryOptions, SimulateBlocksErrorType, SimulateBlocksReturnType>, + "queryFn" | "queryKey" | "queryKeyHashFn" +>;