From 512e598aae8d0a11453db3821826fb2ec1fa7f31 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Fri, 13 Feb 2026 15:05:54 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9E=95=20server:=20install=20gcp=20depen?= =?UTF-8?q?dencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 413 ++++++++++++++++++++++++++++++++++++++++++++ server/package.json | 2 + 2 files changed, 415 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a0ff669..fbc118f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,6 +733,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.10 version: 1.19.10(hono@4.12.7) @@ -769,6 +772,9 @@ importers: '@valibot/to-json-schema': specifier: ^1.5.0 version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + '@valora/viem-account-hsm-gcp': + specifier: ^1.2.16 + version: 1.2.17(viem@2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -3147,6 +3153,19 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@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'} @@ -3574,6 +3593,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==} @@ -4107,6 +4129,10 @@ packages: '@pix.js/validator@1.1.0': resolution: {integrity: sha512-NIYcYwuFblA8/cx7YpNdEEujNjKsnA985jsNgIMcYtY2AVUz646IUbisgTgFu7erN7X5eeQGzELgRnFoPcInVw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4135,6 +4161,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==} @@ -5756,6 +5812,10 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -6218,6 +6278,12 @@ packages: peerDependencies: valibot: ^1.2.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.0.17': resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} peerDependencies: @@ -6784,6 +6850,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==} @@ -6855,6 +6924,9 @@ packages: resolution: {integrity: sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w==} engines: {node: '>= 0.4.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -7554,6 +7626,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'} @@ -7875,9 +7951,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==} @@ -8769,6 +8851,10 @@ packages: fengari@0.1.5: resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -8900,6 +8986,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==} @@ -8977,6 +9067,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'} @@ -9054,6 +9152,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -9115,6 +9218,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'} @@ -9362,6 +9477,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'} @@ -9784,6 +9903,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -9892,6 +10014,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==} @@ -9950,6 +10075,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.27: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true @@ -10124,6 +10255,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==} @@ -10176,6 +10310,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==} @@ -10800,6 +10937,11 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -10812,6 +10954,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -10900,6 +11046,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'} @@ -11429,6 +11579,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 @@ -11970,6 +12128,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'} @@ -11979,6 +12141,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -12362,9 +12528,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'} @@ -12452,6 +12624,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==} @@ -12548,6 +12723,10 @@ packages: resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + temp-dir@1.0.0: resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} engines: {node: '>=4'} @@ -13280,6 +13459,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 @@ -16333,6 +16516,24 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@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 @@ -16700,6 +16901,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': {} @@ -17485,6 +17688,9 @@ snapshots: transitivePeerDependencies: - buffer + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.9': {} @@ -17510,6 +17716,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -19913,6 +20142,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tootallnate/once@2.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -20440,6 +20671,15 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@valora/viem-account-hsm-gcp@1.2.17(viem@2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': + dependencies: + '@google-cloud/kms': 5.4.0 + '@noble/curves': 1.9.7 + asn1js: 3.0.7 + viem: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.17(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -21263,6 +21503,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + birecord@0.1.1: {} bl@4.1.0: @@ -21360,6 +21602,8 @@ snapshots: once: 1.4.0 sliced: 1.0.1 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -22131,6 +22375,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 @@ -22330,8 +22576,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 @@ -23712,6 +23969,11 @@ snapshots: sprintf-js: 1.1.3 tmp: 0.2.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} figures@3.2.0: @@ -23860,6 +24122,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: {} @@ -23949,6 +24215,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: {} @@ -24016,6 +24299,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -24095,6 +24387,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): @@ -24444,6 +24765,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 @@ -24863,6 +25192,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -25002,6 +25337,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: {} @@ -25057,6 +25396,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.27: dependencies: commander: 8.3.0 @@ -25209,6 +25559,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: {} @@ -25258,6 +25610,8 @@ snapshots: split: 0.2.10 through: 2.3.8 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -26419,12 +26773,20 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.3.3: {} node-gyp-build-optional-packages@5.2.2: @@ -26545,6 +26907,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -27115,6 +27479,25 @@ snapshots: proto-list@1.2.4: {} + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.5.4 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 25.0.9 + long: 5.3.2 + protolint@0.56.4: dependencies: got: 12.6.1 @@ -27805,12 +28188,23 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + retry-request@8.0.2: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.0 + transitivePeerDependencies: + - supports-color + reusify@1.1.0: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + robust-predicates@3.0.2: {} rollup-pluginutils@2.8.2: @@ -28328,8 +28722,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: {} @@ -28440,6 +28840,8 @@ snapshots: structured-headers@0.4.1: {} + stubs@3.0.0: {} + sturdy-websocket@0.2.1: optional: true @@ -28612,6 +29014,15 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teeny-request@10.1.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + temp-dir@1.0.0: {} temp-dir@2.0.0: {} @@ -29265,6 +29676,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/package.json b/server/package.json index b5af3a188..c1d9c8a0a 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.10", "@hono/sentry": "^1.2.2", "@hono/valibot-validator": "^0.5.3", @@ -44,6 +45,7 @@ "@simplewebauthn/server": "^13.2.2", "@types/debug": "^4.1.12", "@valibot/to-json-schema": "^1.5.0", + "@valora/viem-account-hsm-gcp": "^1.2.16", "async-mutex": "^0.5.0", "bullmq": "^5.66.5", "debug": "^4.4.3", From efdd6db04e3a3d346beed6e1e2db98c2b94c41df Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Wed, 21 Jan 2026 18:39:44 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20server:=20poke=20account=20afte?= =?UTF-8?q?r=20kyc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/silly-yaks-divide.md | 5 + server/hooks/activity.ts | 217 ++++++++++------------------- server/hooks/persona.ts | 10 ++ server/test/hooks/activity.test.ts | 96 +++++-------- server/test/hooks/persona.test.ts | 215 +++++++++++++++++++++++++++- server/test/utils/keeper.test.ts | 18 +-- server/utils/keeper.ts | 107 +++++++++++++- 7 files changed, 450 insertions(+), 218 deletions(-) create mode 100644 .changeset/silly-yaks-divide.md 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/server/hooks/activity.ts b/server/hooks/activity.ts index 1e1dfa3b1..734b7bef3 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -15,16 +15,12 @@ import createDebug from "debug"; import { eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import * as v from "valibot"; -import { bytesToBigInt, hexToBigInt, withRetry } from "viem"; +import { bytesToBigInt, hexToBigInt } from "viem"; import { - auditorAbi, exaAccountFactoryAbi, - exaPluginAbi, exaPreviewerAbi, exaPreviewerAddress, - marketAbi, - upgradeableModularAccountAbi, wethAddress, } from "@exactly/common/generated/chain"; import { Address, Hash, Hex } from "@exactly/common/validation"; @@ -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/persona.ts b/server/hooks/persona.ts index a3f0c130a..be55bb7c8 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -23,6 +23,7 @@ import { import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; +import keeper from "../utils/keeper"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; @@ -30,6 +31,7 @@ import { customer } from "../utils/sardine"; import validatorHook from "../utils/validatorHook"; import type { InferOutput } from "valibot"; + const Session = pipe( object({ type: literal("inquiry-session"), @@ -303,6 +305,14 @@ export default new Hono().post( }).catch((error: unknown) => { captureException(error, { level: "error", extra: { pandaId: id, referenceId } }); }); + keeper + .poke(account.output, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }) + .catch((error: unknown) => captureException(error, { level: "error" })); } else { captureException(new Error("invalid account address"), { extra: { pandaId: id, referenceId, account: credential.account }, diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 5358eae9d..37a47530e 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -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/persona.test.ts b/server/test/hooks/persona.test.ts index 6c3774e02..ec9f942b5 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -5,17 +5,20 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { hexToBytes, padHex, zeroHash } from "viem"; +import { hexToBytes, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; +import { wethAddress } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/persona"; +import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; +import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; const appClient = testClient(app); @@ -381,7 +384,10 @@ describe("persona hook", () => { }); }); - afterEach(() => vi.resetAllMocks()); + afterEach(async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "persona-ref")); + vi.restoreAllMocks(); + }); it("creates panda and pax user on valid inquiry", async () => { vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" }); @@ -389,7 +395,9 @@ describe("persona hook", () => { vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" }); const response = await appClient.index.$post({ - header: { "persona-signature": "t=1,v1=sha256" }, + header: { + "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df", + }, json: { ...validPayload, data: { @@ -428,6 +436,206 @@ 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: 500, interval: 50 }, + ); + }); }); describe("manteca template", () => { @@ -447,6 +655,7 @@ describe("manteca template", () => { it("handles manteca template and adds document", async () => { vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_manteca" } }); + vi.spyOn(panda, "createUser").mockResolvedValue({ id: "should-not-be-called" }); const response = await appClient.index.$post({ header: { "persona-signature": "t=1,v1=sha256" }, diff --git a/server/test/utils/keeper.test.ts b/server/test/utils/keeper.test.ts index a10c147bd..cb8cbd0f1 100644 --- a/server/test/utils/keeper.test.ts +++ b/server/test/utils/keeper.test.ts @@ -13,9 +13,9 @@ import keeper from "../../utils/keeper"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; +import type { Hash, 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 () => { @@ -87,13 +87,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); await vi.waitUntil(() => blockedHashes.length === 1); const second = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); const sendBlocked = await Promise.allSettled([first, second]); @@ -133,7 +133,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); const mockWaitForTransactionReceipt = vi @@ -143,13 +143,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer 0", op: "test.transfer[0]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const second = keeper.exaSend( { name: "test transfer 1", op: "test.transfer[1]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const sendBlocked = await Promise.allSettled([ @@ -159,7 +159,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index + 2}`, op: `test.transfer[${index + 2}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ]); @@ -176,7 +176,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await vi.waitUntil( @@ -194,7 +194,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index}`, op: `test.transfer[${index}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ); diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 0b767c0d7..c86e247e4 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -5,6 +5,7 @@ import { parse, safeParse } from "valibot"; import { createWalletClient, encodeFunctionData, + erc20Abi, getContractError, http, InvalidInputRpcError, @@ -23,11 +24,20 @@ import { import { privateKeyToAccount } from "viem/accounts"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; -import chain from "@exactly/common/generated/chain"; +import chain, { + auditorAbi, + exaPluginAbi, + exaPreviewerAbi, + exaPreviewerAddress, + marketAbi, + upgradeableModularAccountAbi, + wethAddress, +} from "@exactly/common/generated/chain"; import revertReason from "@exactly/common/revertReason"; import { Address, Hash } from "@exactly/common/validation"; import nonceManager from "./nonceManager"; +import { sendPushNotification } from "./onesignal"; import publicClient, { captureRequests, Requests } from "./publicClient"; import revertFingerprint from "./revertFingerprint"; import traceClient from "./traceClient"; @@ -50,8 +60,11 @@ export default createWalletClient({ ), }).extend(extender); +const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); +const WETH = parse(Address, wethAddress); + export function extender(keeper: WalletClient) { - return { + const base = { exaSend: async ( spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, call: Prettify>, @@ -197,4 +210,94 @@ export function extender(keeper: WalletClient { + const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; + const marketsByAsset = await publicClient + .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) + .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)]))); + + const assetsToPoke: { asset: Address; market: Address | null }[] = []; + + const settled = await Promise.allSettled([ + publicClient + .getBalance({ address: accountAddress }) + .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({ + asset: ETH, + market: null, + balance, + })), + ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({ + asset, + market, + balance: await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }), + })), + ]).then((s) => { + return s.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; + } + return [result.value]; + }); + }); + + const hasETH = settled.some((r) => r.asset === ETH && r.balance > 0n); + for (const { asset, market, balance } of settled) { + if (hasETH && asset === WETH) continue; + if (balance > 0n) assetsToPoke.push({ asset, market }); + } + + const pokes = await Promise.allSettled( + assetsToPoke.map(({ asset, market }) => + base.exaSend( + { + name: "poke account", + op: "exa.poke", + attributes: { account: accountAddress, asset }, + }, + asset === ETH + ? { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "pokeETH", + } + : { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "poke", + args: [market], + }, + ...(options?.ignore ? [{ ignore: options.ignore }] : []), + ), + ), + ).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" })); + } + }, + }; } From 9e0faddacc729401f7d4bf5d7e14c87ba1294e9e Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Wed, 25 Feb 2026 00:32:15 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=9A=9A=20server:=20rename=20keeper=20?= =?UTF-8?q?module=20to=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/hooks/activity.ts | 2 +- server/hooks/block.ts | 2 +- server/hooks/panda.ts | 2 +- server/hooks/persona.ts | 2 +- server/test/api/card.test.ts | 5 +- server/test/e2e.ts | 2 +- server/test/hooks/activity.test.ts | 4 +- server/test/hooks/block.test.ts | 2 +- server/test/hooks/panda.test.ts | 4 +- server/test/hooks/persona.test.ts | 9 +- server/test/mocks/{keeper.ts => accounts.ts} | 12 +- .../{keeper.test.ts => accounts.test.ts} | 4 +- server/utils/{keeper.ts => accounts.ts} | 234 ++++++++++-------- 13 files changed, 154 insertions(+), 130 deletions(-) rename server/test/mocks/{keeper.ts => accounts.ts} (82%) rename server/test/utils/{keeper.test.ts => accounts.test.ts} (99%) rename server/utils/{keeper.ts => accounts.ts} (89%) diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index 734b7bef3..e27aa8cd2 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -26,10 +26,10 @@ import { 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"; 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 be55bb7c8..1559b2369 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -23,7 +23,7 @@ import { import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; -import keeper from "../utils/keeper"; +import { keeper } from "../utils/accounts"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; 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 c5c73ce05..2b426d100 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/redis"; diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 37a47530e..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"; diff --git a/server/test/hooks/block.test.ts b/server/test/hooks/block.test.ts index f3322200d..16763297b 100644 --- a/server/test/hooks/block.test.ts +++ b/server/test/hooks/block.test.ts @@ -48,8 +48,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 9419097c2..ff5ab955e 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/redis"; @@ -47,7 +47,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 ec9f942b5..b3b02e2a4 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -14,7 +14,7 @@ import { wethAddress } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/persona"; -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"; @@ -24,6 +24,13 @@ import * as sardine from "../../utils/sardine"; const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); +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"; diff --git a/server/test/mocks/keeper.ts b/server/test/mocks/accounts.ts similarity index 82% rename from server/test/mocks/keeper.ts rename to server/test/mocks/accounts.ts index 428dd177e..f539510ad 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,23 @@ 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({ + 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 99% rename from server/test/utils/keeper.test.ts rename to server/test/utils/accounts.test.ts index cb8cbd0f1..23da21367 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,7 +9,7 @@ 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"; diff --git a/server/utils/keeper.ts b/server/utils/accounts.ts similarity index 89% rename from server/utils/keeper.ts rename to server/utils/accounts.ts index c86e247e4..b3fa2e801 100644 --- a/server/utils/keeper.ts +++ b/server/utils/accounts.ts @@ -14,6 +14,7 @@ import { WaitForTransactionReceiptTimeoutError, withRetry, type HttpTransport, + type LocalAccount, type MaybePromise, type Prettify, type PrivateKeyAccount, @@ -44,7 +45,7 @@ import traceClient from "./traceClient"; if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); -export default createWalletClient({ +export const keeper = createWalletClient({ chain, transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, { batch: true, @@ -63,8 +64,107 @@ export default createWalletClient({ const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); const WETH = parse(Address, wethAddress); -export function extender(keeper: WalletClient) { - const base = { +export function extender(client: WalletClient) { + const base = withExaSend(client); + + return { + ...base, + poke: async ( + accountAddress: Address, + options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } }, + ) => { + const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; + const marketsByAsset = await publicClient + .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) + .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)]))); + + const assetsToPoke: { asset: Address; market: Address | null }[] = []; + + const settled = await Promise.allSettled([ + publicClient + .getBalance({ address: accountAddress }) + .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({ + asset: ETH, + market: null, + balance, + })), + ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({ + asset, + market, + balance: await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }), + })), + ]).then((s) => { + return s.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; + } + return [result.value]; + }); + }); + + const hasETH = settled.some((r) => r.asset === ETH && r.balance > 0n); + for (const { asset, market, balance } of settled) { + if (hasETH && asset === WETH) continue; + if (balance > 0n) assetsToPoke.push({ asset, market }); + } + + const pokes = await Promise.allSettled( + assetsToPoke.map(({ asset, market }) => + base.exaSend( + { + name: "poke account", + op: "exa.poke", + attributes: { account: accountAddress, asset }, + }, + asset === ETH + ? { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "pokeETH", + } + : { + address: accountAddress, + abi: combinedAccountAbi, + functionName: "poke", + args: [market], + }, + ...(options?.ignore ? [{ ignore: options.ignore }] : []), + ), + ), + ).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>, @@ -72,6 +172,7 @@ export function extender(keeper: WalletClient MaybePromise) | string[]; level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; }, ) => withScope((scope) => @@ -82,7 +183,7 @@ export function extender(keeper: WalletClient - publicClient.simulateContract({ account: keeper.account, ...txOptions, ...call }), + publicClient.simulateContract({ account: client.account, ...txOptions, ...call }), ); const { abi: _, @@ -102,7 +203,7 @@ export function extender(keeper: WalletClient - keeper.prepareTransactionRequest({ + client.prepareTransactionRequest({ to: call.address, data: encodeFunctionData(call), ...txOptions, @@ -112,7 +213,7 @@ export function extender(keeper: WalletClient - keeper.signTransaction(prepared), + client.signTransaction(prepared), ); const hash = keccak256(serializedTransaction); scope.setContext("tx", { request, prepared, hash }); @@ -140,11 +241,11 @@ export function extender(keeper: WalletClient { - const info = nonceManager.info({ address: keeper.account.address, chainId: chain.id }); + 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: keeper.account.address, chainId: chain.id }); + nonceManager.hardReset({ address: client.account.address, chainId: chain.id }); }, ); } @@ -160,16 +261,21 @@ export function extender(keeper: WalletClient - withRetry(() => traceClient.traceTransaction(hash), { - delay: 1000, - retryCount: 10, - shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, - }).catch((error: unknown) => { - captureException(error, { level: "error" }); - return null; - }), - ); + 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"); @@ -210,94 +316,4 @@ export function extender(keeper: WalletClient { - const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; - const marketsByAsset = await publicClient - .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) - .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)]))); - - const assetsToPoke: { asset: Address; market: Address | null }[] = []; - - const settled = await Promise.allSettled([ - publicClient - .getBalance({ address: accountAddress }) - .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({ - asset: ETH, - market: null, - balance, - })), - ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({ - asset, - market, - balance: await publicClient.readContract({ - address: asset, - functionName: "balanceOf", - args: [accountAddress], - abi: erc20Abi, - }), - })), - ]).then((s) => { - return s.flatMap((result) => { - if (result.status === "rejected") { - captureException(result.reason, { level: "error" }); - return []; - } - return [result.value]; - }); - }); - - const hasETH = settled.some((r) => r.asset === ETH && r.balance > 0n); - for (const { asset, market, balance } of settled) { - if (hasETH && asset === WETH) continue; - if (balance > 0n) assetsToPoke.push({ asset, market }); - } - - const pokes = await Promise.allSettled( - assetsToPoke.map(({ asset, market }) => - base.exaSend( - { - name: "poke account", - op: "exa.poke", - attributes: { account: accountAddress, asset }, - }, - asset === ETH - ? { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "pokeETH", - } - : { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "poke", - args: [market], - }, - ...(options?.ignore ? [{ ignore: options.ignore }] : []), - ), - ), - ).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" })); - } - }, - }; } From 0712b3630fb4e3e3aa9101770039946b7e60df13 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 20 Jan 2026 17:26:24 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20server:=20use=20gcp=20kms=20for?= =?UTF-8?q?=20allower?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/lucky-jokes-change.md | 5 ++ .do/app.yaml | 13 ++++ cspell.json | 1 + server/hooks/persona.ts | 70 ++++++++++-------- server/script/openapi.ts | 5 ++ server/test/hooks/persona.test.ts | 113 ++++++++++++++++++++++++++++- server/test/mocks/accounts.ts | 1 + server/test/utils/accounts.test.ts | 18 ++--- server/test/utils/gcp.test.ts | 88 ++++++++++++++++++++++ server/utils/accounts.ts | 106 +++++++++++++++++++++++++-- server/utils/gcp.ts | 72 ++++++++++++++++++ server/vitest.config.mts | 4 + 12 files changed, 448 insertions(+), 48 deletions(-) create mode 100644 .changeset/lucky-jokes-change.md create mode 100644 server/test/utils/gcp.test.ts create mode 100644 server/utils/gcp.ts 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/.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 6877550ca..e9c475888 100644 --- a/cspell.json +++ b/cspell.json @@ -175,6 +175,7 @@ "valibot", "valierror", "valkey", + "valora", "viem", "viewability", "wagmi", diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 1559b2369..e97b69be2 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -13,6 +13,7 @@ import { nullable, object, optional, + parse, pipe, safeParse, string, @@ -20,10 +21,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 { keeper } from "../utils/accounts"; +import { allower, keeper } from "../utils/accounts"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; @@ -32,6 +34,15 @@ 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"), @@ -275,6 +286,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, @@ -291,34 +320,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 } }); - }); - keeper - .poke(account.output, { - notification: { - headings: { en: "Account assets updated" }, - contents: { en: "Your funds are ready to use" }, - }, - }) - .catch((error: unknown) => captureException(error, { level: "error" })); - } 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/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/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index b3b02e2a4..b45e616a6 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -14,16 +14,27 @@ import { wethAddress } from "@exactly/common/generated/chain"; import database, { credentials } from "../../database"; import app from "../../hooks/persona"; -import { keeper } from "../../utils/accounts"; +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 { @@ -48,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({ @@ -640,9 +684,74 @@ describe("persona hook", () => { () => { expect(exaSendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ op: "exa.poke" }), expect.anything()); }, - { timeout: 500, interval: 50 }, + { 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", () => { diff --git a/server/test/mocks/accounts.ts b/server/test/mocks/accounts.ts index f539510ad..639f76e6a 100644 --- a/server/test/mocks/accounts.ts +++ b/server/test/mocks/accounts.ts @@ -20,6 +20,7 @@ vi.mock("../../utils/accounts", async (importOriginal) => { const original = await importOriginal(); return { ...original, + allower: vi.fn(() => Promise.resolve({ allow: vi.fn().mockResolvedValue({}) })), keeper: createWalletClient({ chain, transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`), diff --git a/server/test/utils/accounts.test.ts b/server/test/utils/accounts.test.ts index 23da21367..8225394ff 100644 --- a/server/test/utils/accounts.test.ts +++ b/server/test/utils/accounts.test.ts @@ -13,7 +13,7 @@ import { keeper } from "../../utils/accounts"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; -import type { Hash, Hex } from "@exactly/common/validation"; +import type { Hex } from "@exactly/common/validation"; import type * as sentry from "@sentry/node"; import type * as timers from "node:timers/promises"; @@ -87,13 +87,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => blockedHashes.push(hash) }, + { onHash: (hash) => blockedHashes.push(hash) }, ); await vi.waitUntil(() => blockedHashes.length === 1); const second = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => blockedHashes.push(hash) }, + { onHash: (hash) => blockedHashes.push(hash) }, ); const sendBlocked = await Promise.allSettled([first, second]); @@ -133,7 +133,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ); const mockWaitForTransactionReceipt = vi @@ -143,13 +143,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer 0", op: "test.transfer[0]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ); await setImmediate(); const second = keeper.exaSend( { name: "test transfer 1", op: "test.transfer[1]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ); await setImmediate(); const sendBlocked = await Promise.allSettled([ @@ -159,7 +159,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index + 2}`, op: `test.transfer[${index + 2}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ), ), ]); @@ -176,7 +176,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ); await vi.waitUntil( @@ -194,7 +194,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index}`, op: `test.transfer[${index}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash: Hash) => hashes.push(hash) }, + { onHash: (hash) => hashes.push(hash) }, ), ), ); 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 index b3fa2e801..a8f523b11 100644 --- a/server/utils/accounts.ts +++ b/server/utils/accounts.ts @@ -1,5 +1,7 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; -import { captureException, startSpan, withScope } from "@sentry/node"; +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 { @@ -30,6 +32,8 @@ import chain, { exaPluginAbi, exaPreviewerAbi, exaPreviewerAddress, + firewallAbi, + firewallAddress, marketAbi, upgradeableModularAccountAbi, wethAddress, @@ -37,6 +41,7 @@ import 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"; @@ -45,12 +50,25 @@ 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) { - captureRequests(parse(Requests, await request.json())); + const clone = request.clone(); + captureRequests(parse(Requests, await clone.json())); }, }), account: privateKeyToAccount( @@ -65,10 +83,10 @@ const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); const WETH = parse(Address, wethAddress); export function extender(client: WalletClient) { - const base = withExaSend(client); + const send = withExaSend(client); return { - ...base, + ...send, poke: async ( accountAddress: Address, options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } }, @@ -80,7 +98,7 @@ export function extender(client: WalletClient ({ @@ -108,15 +126,15 @@ export function extender(client: WalletClient r.asset === ETH && r.balance > 0n); - for (const { asset, market, balance } of settled) { + 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 }) => - base.exaSend( + send.exaSend( { name: "poke account", op: "exa.poke", @@ -317,3 +335,75 @@ export function withExaSend( ), }; } + +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/vitest.config.mts b/server/vitest.config.mts index bca929b42..b62b8872a 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", From 5f7769a4679826fe5124476fbd65d4c78ac0838a Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 2 Mar 2026 11:06:47 -0300 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20server:=20retry=20poke?= =?UTF-8?q?=20calls=20on=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/utils/accounts.ts | 41 +++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/server/utils/accounts.ts b/server/utils/accounts.ts index a8f523b11..43bc5d54e 100644 --- a/server/utils/accounts.ts +++ b/server/utils/accounts.ts @@ -134,25 +134,32 @@ export function extender(client: WalletClient - send.exaSend( + 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 }] : []), + ), { - name: "poke account", - op: "exa.poke", - attributes: { account: accountAddress, asset }, + retryCount: 10, + delay: ({ count }) => Math.trunc(1 << count) * 60, }, - asset === ETH - ? { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "pokeETH", - } - : { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "poke", - args: [market], - }, - ...(options?.ignore ? [{ ignore: options.ignore }] : []), ), ), ).then((r) => {