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/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/.do/app.yaml b/.do/app.yaml index a82ea1324..518d88eff 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -84,6 +84,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/cspell.json b/cspell.json index 2801f5c87..82f5554f6 100644 --- a/cspell.json +++ b/cspell.json @@ -176,6 +176,7 @@ "valibot", "valierror", "valkey", + "valora", "viem", "viewability", "wagmi", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e508d08..daa7e41d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -728,6 +728,9 @@ importers: '@exactly/lib': specifier: ^0.1.0 version: 0.1.0 + '@google-cloud/kms': + specifier: ^5.3.0 + version: 5.4.0 '@hono/node-server': specifier: ^1.19.11 version: 1.19.11(hono@4.12.9) @@ -764,6 +767,9 @@ importers: '@valibot/to-json-schema': specifier: ^1.6.0 version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + '@valora/viem-account-hsm-gcp': + specifier: ^1.2.16 + version: 1.2.17(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -3077,6 +3083,19 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@google-cloud/kms@5.4.0': + resolution: {integrity: sha512-+06zUCaJM+wyZISM3F6u/jSqoBs0iZ8Aj9rqOJFePoWkNN7FbR4mQpV7okGHA+Y7caVgq+4QtIDKiFd17SZT+A==} + 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'} @@ -3534,6 +3553,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==} @@ -4132,6 +4154,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==} @@ -5828,6 +5880,10 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -6304,6 +6360,12 @@ packages: peerDependencies: valibot: ^1.3.0 + '@valora/viem-account-hsm-gcp@1.2.17': + resolution: {integrity: sha512-xRQ6C9qIFqQi6JQYGFenQDeiK39RXLbEG+4/uiHqGQqsrTiELm+fOkjhbX4vC+E009zvb340GuSP5QPVPPmOIw==} + engines: {node: '>=20'} + peerDependencies: + viem: ^2.9.20 + '@vitest/coverage-v8@4.1.2': resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} peerDependencies: @@ -6468,6 +6530,10 @@ packages: aes-js@3.0.0: resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -6864,6 +6930,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -6937,6 +7006,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + 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==} @@ -7610,6 +7682,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'} @@ -7929,9 +8005,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==} @@ -8844,6 +8926,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fetch-nodeshim@0.4.10: resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} @@ -8981,6 +9067,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==} @@ -9050,6 +9140,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'} @@ -9195,6 +9293,18 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@10.6.1: + resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} + 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'} @@ -9455,6 +9565,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'} @@ -9467,6 +9581,10 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -9975,6 +10093,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==} @@ -10033,6 +10154,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.44: resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} hasBin: true @@ -10214,6 +10341,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==} @@ -10266,6 +10396,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==} @@ -10940,6 +11073,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-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -10956,6 +11094,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.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -11045,6 +11187,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'} @@ -11567,6 +11713,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 @@ -12115,6 +12269,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'} @@ -12509,9 +12667,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'} @@ -12595,6 +12759,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==} @@ -12690,6 +12857,10 @@ packages: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + temp-dir@3.0.0: resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} engines: {node: '>=14.16'} @@ -13406,6 +13577,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 @@ -16441,6 +16616,24 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@google-cloud/kms@5.4.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 @@ -16808,6 +17001,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': {} @@ -17664,6 +17859,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.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -18358,7 +18576,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 @@ -20071,6 +20289,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tootallnate/once@2.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -20599,6 +20819,15 @@ snapshots: dependencies: valibot: 1.3.1(typescript@5.9.3) + '@valora/viem-account-hsm-gcp@1.2.17(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + '@google-cloud/kms': 5.4.0 + '@noble/curves': 1.9.7 + asn1js: 3.0.7 + viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.1.2(vitest@4.1.2)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -20817,6 +21046,12 @@ snapshots: aes-js@3.0.0: optional: true + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv-draft-04@1.0.0(ajv@8.18.0): @@ -21414,6 +21649,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + birecord@0.1.1: {} bl@4.1.0: @@ -21505,6 +21742,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -22260,6 +22499,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 @@ -22455,8 +22696,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 @@ -23878,6 +24130,11 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fetch-nodeshim@0.4.10: {} fflate@0.8.2: {} @@ -24030,6 +24287,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: {} @@ -24110,6 +24371,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: {} @@ -24265,6 +24543,35 @@ snapshots: globrex@0.1.2: {} + google-auth-library@10.6.1: + 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 + 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.6.1 + 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): @@ -24624,6 +24931,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 @@ -24644,6 +24959,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -25168,6 +25490,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: {} @@ -25223,6 +25549,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.44: dependencies: commander: 8.3.0 @@ -25379,6 +25716,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: {} @@ -25428,6 +25767,8 @@ snapshots: split: 0.2.10 through: 2.3.8 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -26766,6 +27107,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -26779,6 +27122,12 @@ snapshots: 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.4.0: {} node-gyp-build-optional-packages@5.2.2: @@ -26902,6 +27251,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -27468,6 +27819,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.5.0 + long: 5.3.2 + protolint@0.56.4: dependencies: got: 12.6.1 @@ -28181,6 +28551,13 @@ 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: @@ -28730,8 +29107,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: {} @@ -28836,6 +29219,8 @@ snapshots: structured-headers@0.4.1: {} + stubs@3.0.0: {} + sturdy-websocket@0.2.1: optional: true @@ -29000,6 +29385,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@3.0.0: {} tempy@3.2.0: @@ -29622,6 +30016,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/hooks/activity.ts b/server/hooks/activity.ts index 1e1dfa3b1..e27aa8cd2 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -15,25 +15,21 @@ 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"; import database, { cards, credentials } from "../database"; +import { keeper } from "../utils/accounts"; import { createWebhook, findWebhook, headerValidator, network } from "../utils/alchemy"; import appOrigin from "../utils/appOrigin"; import decodePublicKey from "../utils/decodePublicKey"; -import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; import { autoCredit } from "../utils/panda"; import publicClient from "../utils/publicClient"; @@ -96,7 +92,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, source: true }, where: inArray(credentials.account, [...new Set(transfers.map(({ toAddress }) => toAddress))]), @@ -109,18 +105,16 @@ export default new Hono().post( ), ), ); - if (Object.keys(accounts).length === 1) setUser({ id: v.parse(Address, Object.keys(accounts)[0]) }); + if (Object.keys(accountLookup).length === 1) setUser({ id: v.parse(Address, Object.keys(accountLookup)[0]) }); const marketsByAsset = await publicClient .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< - Address, - { assets: Set
; factory: Address; publicKey: Uint8Array; source: null | string } - >(); + + 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; @@ -131,141 +125,84 @@ 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, source } = accounts[account]; - pokes.set(account, { publicKey, factory, source, assets: new Set([asset]) }); - } + accounts.add(account); } const { "sentry-trace": sentryTrace, baggage } = getTraceData(); Promise.allSettled( - [...pokes].map(([account, { publicKey, factory, source, assets }]) => - continueTrace({ sentryTrace, baggage }, () => - withScope((scope) => - startSpan( - { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true }, - async (span) => { - scope.setUser({ id: account }); - const isDeployed = !!(await 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, properties: { source } }); - } 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, source }]) => + 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; - withScope((captureScope) => { - captureScope.setUser({ id: account }); - 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.setUser({ id: account }); - captureScope.addEventProcessor((event) => { - if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance"; - return event; - }); - captureException(result.reason, { - level: "warning", - fingerprint: ["{{ default }}", "NoBalance"], - }); - }); - continue; + ); + track({ event: "AccountFunded", userId: account, properties: { source } }); + } 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, { level: "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, { level: "error" })); + }) + .catch((error: unknown) => captureException(error, { level: "error" })); + span.setStatus({ code: SPAN_STATUS_OK }); + }, + ), ), - ), - ).catch((error: unknown) => { - withScope((scope) => { - scope.setUser({ id: account }); - captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); - }); - throw error; - }), - ), + ).catch((error: unknown) => { + withScope((captureScope) => { + captureScope.setUser({ id: account }); + captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); + }); + throw error; + }), + ), ) .then((results) => { getActiveSpan()?.setStatus( @@ -274,7 +211,7 @@ export default new Hono().post( : { code: SPAN_STATUS_ERROR, message: "activity_failed" }, ); }) - .catch((error: unknown) => captureException(error)); + .catch((error: unknown) => captureException(error, { level: "error" })); return c.json({}); }, ); diff --git a/server/hooks/block.ts b/server/hooks/block.ts index 82a4b0bef..d5517d149 100644 --- a/server/hooks/block.ts +++ b/server/hooks/block.ts @@ -46,10 +46,10 @@ import revertReason from "@exactly/common/revertReason"; import shortenHex from "@exactly/common/shortenHex"; import { Address, Hash, Hex } from "@exactly/common/validation"; +import { keeper } from "../utils/accounts"; import { headers as alchemyHeaders, createWebhook, findWebhook, headerValidator } from "../utils/alchemy"; import appOrigin from "../utils/appOrigin"; import ensClient from "../utils/ensClient"; -import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; import publicClient from "../utils/publicClient"; import redis from "../utils/redis"; diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 348e55731..c50f4e3b0 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -51,7 +51,7 @@ import { Address, type Hash, type Hex } from "@exactly/common/validation"; import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; import database, { cards, credentials, transactions } from "../database/index"; -import keeper from "../utils/keeper"; +import { keeper } from "../utils/accounts"; import { sendPushNotification } from "../utils/onesignal"; import { collectors, createMutex, getMutex, getUser, headerValidator, signIssuerOp, updateUser } from "../utils/panda"; import publicClient from "../utils/publicClient"; diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 1252d71fa..dcb8ceef7 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -13,6 +13,7 @@ import { nullable, object, optional, + parse, picklist, pipe, safeParse, @@ -21,9 +22,11 @@ import { union, } from "valibot"; +import { firewallAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; +import { allower, keeper } from "../utils/accounts"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { @@ -39,6 +42,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"), @@ -300,6 +313,24 @@ 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); + } + keeper + .poke(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }) + .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, @@ -316,26 +347,17 @@ 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", - }); - } + 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", extra: { pandaId: id, referenceId } })); + addDocument(referenceId, { id_class: { value: fields.identificationClass.value }, id_number: { value: fields.identificationNumber.value }, diff --git a/server/package.json b/server/package.json index 79710d98a..a828985c6 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.11", "@hono/sentry": "^1.2.2", "@hono/valibot-validator": "^0.5.3", @@ -44,6 +45,7 @@ "@simplewebauthn/server": "^13.3.0", "@types/debug": "^4.1.13", "@valibot/to-json-schema": "^1.6.0", + "@valora/viem-account-hsm-gcp": "^1.2.16", "async-mutex": "^0.5.0", "bullmq": "^5.71.1", "debug": "^4.4.3", diff --git a/server/script/openapi.ts b/server/script/openapi.ts index 124c51240..60c0f59a6 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -29,6 +29,11 @@ process.env.REDIS_URL = "redis"; process.env.SARDINE_API_KEY = "sardine"; process.env.SARDINE_API_URL = "https://api.sardine.ai"; process.env.SEGMENT_WRITE_KEY = "segment"; +process.env.GCP_BASE64_JSON = "base64String=="; +process.env.GOOGLE_APPLICATION_CREDENTIALS = "path/to/credentials.json"; +process.env.GCP_KMS_KEY_RING = "op-sepolia"; +process.env.GCP_KMS_KEY_VERSION = "1"; +process.env.GCP_PROJECT_ID = "exa-dev"; /* eslint-disable n/no-process-exit, unicorn/no-process-exit, no-console -- cli */ import("../api") diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c24490..e1310ba42 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -1,7 +1,6 @@ +import "../mocks/accounts"; import "../mocks/auth"; import "../mocks/deployments"; -import "../mocks/keeper"; -import "../mocks/onesignal"; import "../mocks/pax"; import "../mocks/persona"; @@ -20,7 +19,7 @@ import { Address } from "@exactly/common/validation"; import app from "../../api/card"; import database, { cards, credentials } from "../../database"; -import keeper from "../../utils/keeper"; +import { keeper } from "../../utils/accounts"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0..be8f94a99 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -1,7 +1,7 @@ /// +import "./mocks/accounts"; import "./mocks/alchemy"; import "./mocks/deployments"; -import "./mocks/keeper"; import "./mocks/onesignal"; import "./mocks/pax"; import "./mocks/sardine"; diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 5358eae9d..1ec802bbd 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -1,6 +1,6 @@ +import "../mocks/accounts"; import "../mocks/alchemy"; import "../mocks/deployments"; -import "../mocks/keeper"; import "../mocks/onesignal"; import "../mocks/sentry"; @@ -28,8 +28,8 @@ import { exaAccountFactoryAbi, previewerAbi } from "@exactly/common/generated/ch import database, { credentials } from "../../database"; import app from "../../hooks/activity"; +import { keeper } from "../../utils/accounts"; import * as decodePublicKey from "../../utils/decodePublicKey"; -import keeper from "../../utils/keeper"; import * as onesignal from "../../utils/onesignal"; import publicClient from "../../utils/publicClient"; import anvilClient from "../anvilClient"; @@ -55,40 +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(setUser).toHaveBeenCalledWith({ id: account }); - expect(response.status).toBe(200); - }); - it("fails with unexpected error", async () => { const getCode = vi.spyOn(publicClient, "getCode"); getCode.mockRejectedValue(new Error("Unexpected")); @@ -310,6 +276,7 @@ describe("address activity", () => { }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -342,6 +309,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, @@ -374,6 +342,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, @@ -404,6 +373,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, @@ -432,6 +402,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, @@ -505,7 +476,7 @@ describe("address activity", () => { }); it("pokes eth with value when rawValue is 0x", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const pokeSpy = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -525,22 +496,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(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] })); 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 pokeSpy = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -568,15 +531,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(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] })); expect(market.floatingDepositAssets).toBe(deposit); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); @@ -620,7 +575,7 @@ describe("address activity", () => { }); it("pokes token without value", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const pokeSpy = vi.spyOn(keeper, "poke"); const weth = parseEther("2"); await keeper.exaSend( { name: "mint", op: "tx.mint" }, @@ -651,15 +606,7 @@ 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(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] })); expect(market.floatingDepositAssets).toBe(weth); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); @@ -807,6 +754,27 @@ describe("address activity", () => { expect(setUser).toHaveBeenCalledWith({ id: account }); 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.some(([addr]) => addr === account), { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledWith(account, { ignore: [`NotAllowed(${account})`] }); + }); }); async function getWETHMarket(account: Address) { diff --git a/server/test/hooks/block.test.ts b/server/test/hooks/block.test.ts index 2ef532e43..3ccb51416 100644 --- a/server/test/hooks/block.test.ts +++ b/server/test/hooks/block.test.ts @@ -47,8 +47,8 @@ import ProposalType, { decodeWithdraw } from "@exactly/common/ProposalType"; import deploy from "@exactly/plugin/deploy.json"; import app from "../../hooks/block"; +import { keeper } from "../../utils/accounts"; import ensClient from "../../utils/ensClient"; -import keeper from "../../utils/keeper"; import * as onesignal from "../../utils/onesignal"; import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index d3b8145bf..31176ffc9 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -1,5 +1,5 @@ +import "../mocks/accounts"; import "../mocks/deployments"; -import "../mocks/keeper"; import "../mocks/onesignal"; import "../mocks/panda"; import "../mocks/sardine"; @@ -46,7 +46,7 @@ import { proposalManager } from "@exactly/plugin/deploy.json"; import database, { cards, credentials, transactions } from "../../database"; import app from "../../hooks/panda"; -import keeper from "../../utils/keeper"; +import { keeper } from "../../utils/accounts"; import * as panda from "../../utils/panda"; import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 87599f912..83da8c5f8 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -5,22 +5,43 @@ 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, beforeEach, 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 { allower, keeper } from "../../utils/accounts"; 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"; +import type * as AccountsModule from "../../utils/accounts"; + const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); +const mockAllow = vi.fn().mockResolvedValue({}); + +vi.mock("../../utils/accounts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + allower: 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"; @@ -38,8 +59,41 @@ describe("with reference", () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, referenceId)); }); + it("returns firewall error when allower initialization fails", async () => { + vi.mocked(allower).mockRejectedValueOnce(new Error("allower init failed")); + vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); + const response = await appClient.index.$post({ + ...personaPayload, + json: { + ...personaPayload.json, + data: { + ...personaPayload.json.data, + attributes: { + ...personaPayload.json.data.attributes, + payload: { + ...personaPayload.json.data.attributes.payload, + data: { + ...personaPayload.json.data.attributes.payload.data, + attributes: { + ...personaPayload.json.data.attributes.payload.data.attributes, + referenceId, + }, + }, + included: [...personaPayload.json.data.attributes.payload.included], + }, + }, + }, + }, + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ code: "firewall error" }); + expect(captureException).toHaveBeenCalledWith(new Error("allower init failed"), { level: "error" }); + }); + it("creates a panda account", async () => { vi.spyOn(panda, "createUser").mockResolvedValueOnce({ id: "pandaId" }); + vi.spyOn(pax, "addCapita").mockResolvedValue({}); vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_123" } }); const response = await appClient.index.$post({ @@ -384,7 +438,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" }); @@ -392,7 +449,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: { @@ -431,6 +490,271 @@ 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(undefined 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("captures exception when keeper.poke fails", async () => { + const pokeSpy = vi.spyOn(keeper, "poke").mockRejectedValueOnce(new Error("poke failed")); + 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(captureException).toHaveBeenCalledWith( + new Error("poke failed"), + expect.objectContaining({ level: "error" }), + ); + }); + pokeSpy.mockRestore(); + }); + + 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", () => { @@ -450,6 +774,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/mocks/keeper.ts b/server/test/mocks/accounts.ts similarity index 79% rename from server/test/mocks/keeper.ts rename to server/test/mocks/accounts.ts index 428dd177e..639f76e6a 100644 --- a/server/test/mocks/keeper.ts +++ b/server/test/mocks/accounts.ts @@ -1,3 +1,5 @@ +import "./deployments"; + import path from "node:path"; import { createWalletClient, http, keccak256, toBytes, type NonceManagerSource } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -7,23 +9,24 @@ import { expect, vi } from "vitest"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; import chain from "@exactly/common/generated/chain"; -import type * as keeper from "../../utils/keeper"; +import type * as accounts from "../../utils/accounts"; import type * as nonceManager from "../../utils/nonceManager"; export let keeperClient: ReturnType< typeof createWalletClient, typeof chain, ReturnType> >; -vi.mock("../../utils/keeper", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("../../utils/accounts", async (importOriginal) => { + const original = await importOriginal(); return { ...original, - default: createWalletClient({ + allower: vi.fn(() => Promise.resolve({ allow: vi.fn().mockResolvedValue({}) })), + keeper: createWalletClient({ chain, transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`), account: privateKeyToAccount( keccak256(toBytes(path.relative(path.resolve(__dirname, ".."), expect.getState().testPath ?? ""))), // eslint-disable-line unicorn/prefer-module - { nonceManager: original.default.account.nonceManager }, + { nonceManager: original.keeper.account.nonceManager }, ), }).extend((closureClient) => { keeperClient = closureClient; diff --git a/server/test/utils/keeper.test.ts b/server/test/utils/accounts.test.ts similarity index 98% rename from server/test/utils/keeper.test.ts rename to server/test/utils/accounts.test.ts index a10c147bd..8225394ff 100644 --- a/server/test/utils/keeper.test.ts +++ b/server/test/utils/accounts.test.ts @@ -1,5 +1,5 @@ +import { keeperClient, nonceSource } from "../mocks/accounts"; import "../mocks/deployments"; -import { keeperClient, nonceSource } from "../mocks/keeper"; import "../mocks/sentry"; import { captureException, withScope } from "@sentry/node"; @@ -9,13 +9,13 @@ import { afterEach, describe, expect, inject, it, vi } from "vitest"; import { auditorAbi } from "@exactly/common/generated/chain"; -import keeper from "../../utils/keeper"; +import { keeper } from "../../utils/accounts"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; +import type { Hex } from "@exactly/common/validation"; import type * as sentry from "@sentry/node"; import type * as timers from "node:timers/promises"; -import type { Hex } from "viem"; describe("fault tolerance", () => { it("recovers if transaction is missing", async () => { diff --git a/server/test/utils/gcp.test.ts b/server/test/utils/gcp.test.ts new file mode 100644 index 000000000..15c1a2035 --- /dev/null +++ b/server/test/utils/gcp.test.ts @@ -0,0 +1,88 @@ +import { access, writeFile } from "node:fs/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { initializeGcpCredentials, isRetryableKmsError, 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(); + resetGcpInitialization(); + mockAccess.mockRejectedValue(new Error("File not found")); + }); + + it("creates credentials file with secure permissions (0o600)", async () => { + await initializeGcpCredentials(); + + expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), { + mode: 0o600, + }); + }); + + it("returns early when credentials already exist", async () => { + mockAccess.mockResolvedValue(); + + await initializeGcpCredentials(); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); +}); + +describe("isRetryableKmsError", () => { + it("returns false for non-Error values", () => { + expect(isRetryableKmsError("string")).toBe(false); + expect(isRetryableKmsError(null)).toBe(false); + expect(isRetryableKmsError(42)).toBe(false); + }); + + it("returns true for numeric gRPC codes (14, 4, 13, 8)", () => { + for (const code of [14, 4, 13, 8]) { + const error = Object.assign(new Error("grpc error"), { code }); + expect(isRetryableKmsError(error)).toBe(true); + } + }); + + it("returns false for non-retryable numeric codes", () => { + const error = Object.assign(new Error("grpc error"), { code: 3 }); + expect(isRetryableKmsError(error)).toBe(false); + }); + + it("returns true for string gRPC codes", () => { + for (const code of ["UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL", "RESOURCE_EXHAUSTED"]) { + const error = Object.assign(new Error("grpc error"), { code }); + expect(isRetryableKmsError(error)).toBe(true); + } + }); + + it("returns false for non-retryable string codes", () => { + const error = Object.assign(new Error("grpc error"), { code: "PERMISSION_DENIED" }); + expect(isRetryableKmsError(error)).toBe(false); + }); + + it("returns true for retryable message substrings", () => { + for (const message of ["network error", "request timeout", "service unavailable", "internal error occurred"]) { + expect(isRetryableKmsError(new Error(message))).toBe(true); + } + }); + + it("returns true for retryable error names", () => { + const networkError = new Error("fail"); + networkError.name = "NetworkError"; + expect(isRetryableKmsError(networkError)).toBe(true); + + const timeoutError = new Error("fail"); + timeoutError.name = "TimeoutError"; + expect(isRetryableKmsError(timeoutError)).toBe(true); + }); + + it("returns false for generic errors without retryable signals", () => { + expect(isRetryableKmsError(new Error("something went wrong"))).toBe(false); + }); +}); diff --git a/server/utils/accounts.ts b/server/utils/accounts.ts new file mode 100644 index 000000000..43bc5d54e --- /dev/null +++ b/server/utils/accounts.ts @@ -0,0 +1,416 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; +import { captureException, captureMessage, startSpan, withScope } from "@sentry/node"; +import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; +import { setTimeout } from "node:timers/promises"; +import { parse, safeParse } from "valibot"; +import { + createWalletClient, + encodeFunctionData, + erc20Abi, + getContractError, + http, + InvalidInputRpcError, + keccak256, + RawContractError, + WaitForTransactionReceiptTimeoutError, + withRetry, + type HttpTransport, + type LocalAccount, + 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, { + auditorAbi, + exaPluginAbi, + exaPreviewerAbi, + exaPreviewerAddress, + firewallAbi, + firewallAddress, + marketAbi, + upgradeableModularAccountAbi, + wethAddress, +} from "@exactly/common/generated/chain"; +import revertReason from "@exactly/common/revertReason"; +import { Address, Hash } from "@exactly/common/validation"; + +import { GOOGLE_APPLICATION_CREDENTIALS, hasCredentials, initializeGcpCredentials, isRetryableKmsError } from "./gcp"; +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"); + +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 const keeper = createWalletClient({ + chain, + transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, { + batch: true, + async onFetchRequest(request) { + const clone = request.clone(); + captureRequests(parse(Requests, await clone.json())); + }, + }), + account: privateKeyToAccount( + parse(Hash, process.env.KEEPER_PRIVATE_KEY, { + message: "invalid keeper private key", + }), + { nonceManager }, + ), +}).extend(extender); + +const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); +const WETH = parse(Address, wethAddress); + +export function extender(client: WalletClient) { + const send = withExaSend(client); + + return { + ...send, + 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 balances = 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 = balances.some((r) => r.asset === ETH && r.balance > 0n); + for (const { asset, market, balance } of balances) { + if (hasETH && asset === WETH) continue; + if (balance > 0n) assetsToPoke.push({ asset, market }); + } + + const pokes = await Promise.allSettled( + assetsToPoke.map(({ asset, market }) => + withRetry( + () => + send.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 }] : []), + ), + { + 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" })); + } + }, + }; +} + +export function withExaSend( + client: WalletClient & { + account: LocalAccount; + }, +) { + 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) => { + const account = safeParse(Address, spanOptions.attributes?.account); + if (account.success) scope.setUser({ id: account.output }); + 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" }, () => + client.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: 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, + }).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; + } + }), + ), + }; +} + +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 async function allower() { + return createWalletClient({ + chain, + transport: http(`${chain.rpcUrls.alchemy.http[0]}/${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 send = withExaSend(client); + return { + ...send, + allow: async (account: Address, options?: { ignore?: string[] }) => { + if (!firewallAddress) throw new Error("firewall address not configured"); + return send.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/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 deleted file mode 100644 index 0b767c0d7..000000000 --- a/server/utils/keeper.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; -import { captureException, startSpan, withScope } from "@sentry/node"; -import { setTimeout } from "node:timers/promises"; -import { parse, safeParse } from "valibot"; -import { - createWalletClient, - encodeFunctionData, - getContractError, - 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 { Address, Hash } from "@exactly/common/validation"; - -import nonceManager from "./nonceManager"; -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"); - -export default createWalletClient({ - chain, - transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, { - batch: true, - async onFetchRequest(request) { - captureRequests(parse(Requests, await request.json())); - }, - }), - account: privateKeyToAccount( - parse(Hash, process.env.KEEPER_PRIVATE_KEY, { - message: "invalid keeper private key", - }), - { nonceManager }, - ), -}).extend(extender); - -export function extender(keeper: WalletClient) { - 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) => { - const account = safeParse(Address, spanOptions.attributes?.account); - if (account.success) scope.setUser({ id: account.output }); - 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" }), - ), - ]); - 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; - } - }), - ), - }; -} diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 4d9ff859d..3d4b63e2b 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",