diff --git a/.changeset/kind-ants-beam.md b/.changeset/kind-ants-beam.md
new file mode 100644
index 000000000..437312714
--- /dev/null
+++ b/.changeset/kind-ants-beam.md
@@ -0,0 +1,5 @@
+---
+"@exactly/server": patch
+---
+
+🐛 fix chain mock in tests
diff --git a/.changeset/lucky-jokes-change.md b/.changeset/lucky-jokes-change.md
new file mode 100644
index 000000000..ec7d16fad
--- /dev/null
+++ b/.changeset/lucky-jokes-change.md
@@ -0,0 +1,5 @@
+---
+"@exactly/server": patch
+---
+
+✨ use gcp kms for allower
diff --git a/.changeset/silly-yaks-divide.md b/.changeset/silly-yaks-divide.md
new file mode 100644
index 000000000..d3f7700c5
--- /dev/null
+++ b/.changeset/silly-yaks-divide.md
@@ -0,0 +1,5 @@
+---
+"@exactly/server": patch
+---
+
+✨ poke account after kyc
diff --git a/.do/app.yaml b/.do/app.yaml
index 28204aac2..cce0de126 100644
--- a/.do/app.yaml
+++ b/.do/app.yaml
@@ -81,6 +81,19 @@ services:
- key: DEBUG
scope: RUN_TIME
value: ${{ env.DEBUG }}
+ - key: GCP_KMS_KEY_RING
+ scope: RUN_TIME
+ value: ${{ env.GCP_KMS_KEY_RING }}
+ - key: GCP_KMS_KEY_VERSION
+ scope: RUN_TIME
+ value: ${{ env.GCP_KMS_KEY_VERSION }}
+ - key: GCP_PROJECT_ID
+ scope: RUN_TIME
+ value: ${{ env.GCP_PROJECT_ID }}
+ - key: GCP_BASE64_JSON
+ scope: RUN_TIME
+ type: SECRET
+ value: ${{ env.ENCRYPTED_GCP_BASE64_JSON || env.GCP_BASE64_JSON }}
- key: INTERCOM_IDENTITY_KEY
scope: RUN_TIME
type: SECRET
diff --git a/cspell.json b/cspell.json
index e6a921dea..259579def 100644
--- a/cspell.json
+++ b/cspell.json
@@ -165,6 +165,7 @@
"valibot",
"valierror",
"valkey",
+ "valora",
"viem",
"viewability",
"wagmi",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd3d3eb00..619d7539d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -702,6 +702,9 @@ importers:
'@exactly/lib':
specifier: ^0.1.0
version: 0.1.0
+ '@google-cloud/kms':
+ specifier: ^5.3.0
+ version: 5.3.0
'@hono/node-server':
specifier: ^1.19.9
version: 1.19.9(hono@4.11.7)
@@ -735,6 +738,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.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@@ -2954,6 +2960,19 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+ '@google-cloud/kms@5.3.0':
+ resolution: {integrity: sha512-OJiV7AXOSDjb4sLtVUoTkCPTVxumktZZUgALBAbQnBpPeTtWfzvwqBunsXi41Zp5N6WjSrf69s6c9/M9PGoyjQ==}
+ engines: {node: '>=18'}
+
+ '@grpc/grpc-js@1.14.3':
+ resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
+ engines: {node: '>=12.10.0'}
+
+ '@grpc/proto-loader@0.8.0':
+ resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
+ engines: {node: '>=6'}
+ hasBin: true
+
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
engines: {node: '>=14.0.0'}
@@ -3389,6 +3408,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==}
@@ -3913,6 +3935,10 @@ packages:
peerDependencies:
typescript: ^5.9.3
+ '@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}
@@ -3941,6 +3967,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==}
@@ -5516,6 +5572,10 @@ packages:
'@tanstack/store@0.8.0':
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
+ '@tootallnate/once@2.0.0':
+ resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
+ engines: {node: '>= 10'}
+
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@@ -5976,6 +6036,12 @@ packages:
peerDependencies:
valibot: ^1.2.0
+ '@valora/viem-account-hsm-gcp@1.2.16':
+ resolution: {integrity: sha512-JaxVDEmUHKkJ2ox4yt/4GxKcU1NtHujxW7cux9fHC6rRajdPjxl3HBWwPZ3yqhMFSxfvfdicXuCQhDmqeAXlaw==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ viem: ^2.9.20
+
'@vitest/coverage-v8@4.0.17':
resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==}
peerDependencies:
@@ -6531,6 +6597,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==}
@@ -6595,6 +6664,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==}
@@ -7279,6 +7351,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'}
@@ -7589,9 +7665,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==}
@@ -8475,6 +8557,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==}
@@ -8603,6 +8689,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==}
@@ -8680,6 +8770,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'}
@@ -8757,6 +8855,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ glob@10.5.0:
+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ hasBin: true
+
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
@@ -8818,6 +8920,18 @@ packages:
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ google-auth-library@10.5.0:
+ resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
+ engines: {node: '>=18'}
+
+ google-gax@5.0.6:
+ resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@1.1.3:
+ resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
+ engines: {node: '>=14'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -8846,6 +8960,10 @@ packages:
resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ gtoken@8.0.0:
+ resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
+ engines: {node: '>=18'}
+
h3@1.15.5:
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==}
@@ -9059,6 +9177,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'}
@@ -9471,6 +9593,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}
@@ -9576,6 +9701,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==}
@@ -9634,6 +9762,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
@@ -9805,6 +9939,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==}
@@ -9857,6 +9994,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==}
@@ -10482,6 +10622,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==}
@@ -10494,6 +10639,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'}
@@ -10579,6 +10728,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'}
@@ -11098,6 +11251,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
@@ -11620,6 +11781,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'}
@@ -11629,6 +11794,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==}
@@ -12005,9 +12174,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'}
@@ -12095,6 +12270,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==}
@@ -12188,6 +12366,10 @@ packages:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
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'}
@@ -12916,6 +13098,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
@@ -15897,6 +16083,24 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
+ '@google-cloud/kms@5.3.0':
+ dependencies:
+ google-gax: 5.0.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@grpc/grpc-js@1.14.3':
+ dependencies:
+ '@grpc/proto-loader': 0.8.0
+ '@js-sdsl/ordered-map': 4.4.2
+
+ '@grpc/proto-loader@0.8.0':
+ dependencies:
+ lodash.camelcase: 4.3.0
+ long: 5.3.2
+ protobufjs: 7.5.4
+ yargs: 17.7.2
+
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -16270,6 +16474,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': {}
@@ -17031,6 +17237,9 @@ snapshots:
esquery: 1.7.0
typescript: 5.9.3
+ '@pkgjs/parseargs@0.11.0':
+ optional: true
+
'@pkgr/core@0.1.2': {}
'@pkgr/core@0.2.9': {}
@@ -17056,6 +17265,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)':
@@ -17602,7 +17834,7 @@ snapshots:
'@scure/bip32@1.7.0':
dependencies:
- '@noble/curves': 1.9.1
+ '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
@@ -19354,6 +19586,8 @@ snapshots:
'@tanstack/store@0.8.0': {}
+ '@tootallnate/once@2.0.0': {}
+
'@trysound/sax@0.2.0': {}
'@tybys/wasm-util@0.10.1':
@@ -19875,6 +20109,15 @@ snapshots:
dependencies:
valibot: 1.2.0(typescript@5.9.3)
+ '@valora/viem-account-hsm-gcp@1.2.16(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))':
+ dependencies:
+ '@google-cloud/kms': 5.3.0
+ '@noble/curves': 1.9.7
+ asn1js: 3.0.7
+ viem: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/coverage-v8@4.0.17(vitest@4.0.17)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
@@ -20711,6 +20954,8 @@ snapshots:
big-integer@1.6.52: {}
+ bignumber.js@9.3.1: {}
+
birecord@0.1.1: {}
bl@4.1.0:
@@ -20799,6 +21044,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:
@@ -21558,6 +21805,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
@@ -21751,8 +22000,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
@@ -23101,6 +23361,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:
@@ -23237,6 +23502,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: {}
@@ -23326,6 +23595,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: {}
@@ -23393,6 +23679,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob@10.5.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 3.4.3
+ minimatch: 9.0.5
+ minipass: 7.1.2
+ package-json-from-dist: 1.0.1
+ path-scurry: 1.11.1
+
glob@11.1.0:
dependencies:
foreground-child: 3.3.1
@@ -23472,6 +23767,36 @@ snapshots:
globrex@0.1.2: {}
+ google-auth-library@10.5.0:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.1.3
+ gcp-metadata: 8.1.2
+ google-logging-utils: 1.1.3
+ gtoken: 8.0.0
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ google-gax@5.0.6:
+ dependencies:
+ '@grpc/grpc-js': 1.14.3
+ '@grpc/proto-loader': 0.8.0
+ duplexify: 4.1.3
+ google-auth-library: 10.5.0
+ google-logging-utils: 1.1.3
+ node-fetch: 3.3.2
+ object-hash: 3.0.0
+ proto3-json-serializer: 3.0.4
+ protobufjs: 7.5.4
+ retry-request: 8.0.2
+ rimraf: 5.0.10
+ transitivePeerDependencies:
+ - supports-color
+
+ google-logging-utils@1.1.3: {}
+
gopd@1.2.0: {}
got-fetch@5.1.10(got@12.6.1):
@@ -23503,6 +23828,13 @@ snapshots:
graphql@16.12.0: {}
+ gtoken@8.0.0:
+ dependencies:
+ gaxios: 7.1.3
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
h3@1.15.5:
dependencies:
cookie-es: 1.2.2
@@ -23815,6 +24147,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
@@ -24228,6 +24568,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
@@ -24363,6 +24709,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: {}
@@ -24418,6 +24768,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
@@ -24565,6 +24926,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: {}
@@ -24614,6 +24977,8 @@ snapshots:
split: 0.2.10
through: 2.3.8
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -25777,12 +26142,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:
@@ -25899,6 +26272,8 @@ snapshots:
object-deep-merge@2.0.0: {}
+ object-hash@3.0.0: {}
+
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -26478,6 +26853,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
@@ -27149,12 +27543,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:
@@ -27668,8 +28073,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: {}
@@ -27780,6 +28191,8 @@ snapshots:
structured-headers@0.4.1: {}
+ stubs@3.0.0: {}
+
sturdy-websocket@0.2.1:
optional: true
@@ -27950,6 +28363,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: {}
@@ -28611,6 +29033,8 @@ snapshots:
web-namespaces@2.0.1: {}
+ web-streams-polyfill@3.3.3: {}
+
webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: {}
webauthn-p256@0.0.10:
diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts
index f3f022a48..76dff5bf7 100644
--- a/server/hooks/activity.ts
+++ b/server/hooks/activity.ts
@@ -14,16 +14,12 @@ import createDebug from "debug";
import { eq, inArray } from "drizzle-orm";
import { Hono } from "hono";
import * as v from "valibot";
-import { bytesToBigInt, withRetry } from "viem";
+import { bytesToBigInt } from "viem";
import {
- auditorAbi,
exaAccountFactoryAbi,
- exaPluginAbi,
exaPreviewerAbi,
exaPreviewerAddress,
- marketAbi,
- upgradeableModularAccountAbi,
wethAddress,
} from "@exactly/common/generated/chain";
import { Address, Hash } from "@exactly/common/validation";
@@ -35,6 +31,7 @@ import decodePublicKey from "../utils/decodePublicKey";
import keeper from "../utils/keeper";
import { sendPushNotification } from "../utils/onesignal";
import { autoCredit } from "../utils/panda";
+import { pokeAccountAssets } from "../utils/persona";
import publicClient from "../utils/publicClient";
import { track } from "../utils/segment";
import validatorHook from "../utils/validatorHook";
@@ -107,9 +104,11 @@ export default new Hono().post(
.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi })
.then((p) => new Map
(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)])));
const markets = new Set(marketsByAsset.values());
- const pokes = new Map; factory: Address; publicKey: Uint8Array }>();
+
+ const accountsToProcess = new Set();
for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) {
if (!accounts[account]) continue;
+ // skip notifications for market share transfers
if (rawContract?.address && markets.has(rawContract.address)) continue;
const asset = rawContract?.address ?? ETH;
const underlying = asset === ETH ? WETH : asset;
@@ -120,112 +119,86 @@ export default new Hono().post(
en: `${value ? `${value} ` : ""}${assetSymbol} received${marketsByAsset.has(underlying) ? " and instantly started earning yield" : ""}`,
},
}).catch((error: unknown) => captureException(error));
-
- if (pokes.has(account)) {
- pokes.get(account)?.assets.add(asset);
- } else {
- const { publicKey, factory } = accounts[account];
- pokes.set(account, { publicKey, factory, assets: new Set([asset]) });
- }
+ accountsToProcess.add(account);
}
const { "sentry-trace": sentryTrace, baggage } = getTraceData();
Promise.allSettled(
- [...pokes.entries()].map(([account, { publicKey, factory, assets }]) =>
- continueTrace({ sentryTrace, baggage }, () =>
- withScope((scope) =>
- startSpan(
- { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
- async (span) => {
- scope.setUser({ id: account });
- scope.setTag("exa.account", account);
- const isDeployed = !!(await publicClient.getCode({ address: account }));
- scope.setTag("exa.new", !isDeployed);
- if (!isDeployed) {
- try {
- await keeper.exaSend(
- { name: "create account", op: "exa.account", attributes: { account } },
- {
- address: factory,
- functionName: "createAccount",
- args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
- abi: exaAccountFactoryAbi,
- },
- );
- track({ event: "AccountFunded", userId: account });
- } catch (error: unknown) {
- span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
- throw error;
- }
- }
- if (assets.has(ETH)) assets.delete(WETH);
- const results = await Promise.allSettled(
- [...assets]
- .filter((asset) => marketsByAsset.has(asset) || asset === ETH)
- .map(async (asset) =>
- withRetry(
- () =>
- keeper.exaSend(
- { name: "poke account", op: "exa.poke", attributes: { account, asset } },
- {
- address: account,
- abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi],
- ...(asset === ETH
- ? { functionName: "pokeETH" }
- : {
- functionName: "poke",
- args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion
- }),
- },
- ),
+ [...accountsToProcess]
+ .map((account) => {
+ const accountInfo = accounts[account];
+ if (!accountInfo) return null;
+ const { publicKey, factory } = accountInfo;
+ return [account, { publicKey, factory }] as const;
+ })
+ .filter((item): item is [Address, { factory: Address; publicKey: Uint8Array }] => item !== null)
+ .map(([account, { publicKey, factory }]) =>
+ continueTrace({ sentryTrace, baggage }, () =>
+ withScope((scope) =>
+ startSpan(
+ { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
+ async (span) => {
+ scope.setUser({ id: account });
+ scope.setTag("exa.account", account);
+ const isDeployed = !!(await publicClient.getCode({ address: account }));
+ scope.setTag("exa.new", !isDeployed);
+ if (!isDeployed) {
+ try {
+ await keeper.exaSend(
+ { name: "create account", op: "exa.account", attributes: { account } },
{
- delay: 2000,
- retryCount: 5,
- shouldRetry: ({ error }) => {
- captureException(error, { level: "error" });
- return true;
- },
+ address: factory,
+ functionName: "createAccount",
+ args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
+ abi: exaAccountFactoryAbi,
},
- ),
- ),
- );
- for (const result of results) {
- if (result.status === "fulfilled") continue;
- span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" });
- throw result.reason;
- }
- autoCredit(account)
- .then(async (auto) => {
+ );
+ track({ event: "AccountFunded", userId: account });
+ } catch (error: unknown) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
+ throw error;
+ }
+ }
+ await pokeAccountAssets(account, { ignore: [`NotAllowed(${account})`] }).catch((error: unknown) =>
+ captureException(error),
+ );
+ try {
+ const auto = await autoCredit(account);
span.setAttribute("exa.autoCredit", auto);
- if (!auto) return;
- const credential = await database.query.credentials.findFirst({
- where: eq(credentials.account, account),
- columns: {},
- with: {
- cards: {
- columns: { id: true, mode: true },
- where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
+ if (auto) {
+ const credential = await database.query.credentials.findFirst({
+ where: eq(credentials.account, account),
+ columns: {},
+ with: {
+ cards: {
+ columns: { id: true, mode: true },
+ where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
+ },
},
- },
- });
- if (!credential || credential.cards.length === 0) return;
- const card = credential.cards[0];
- span.setAttribute("exa.card", card?.id);
- if (card?.mode !== 0) return;
- await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
- span.setAttribute("exa.mode", 1);
- sendPushNotification({
- userId: account,
- headings: { en: "Card mode changed" },
- contents: { en: "Credit mode activated" },
- }).catch((error: unknown) => captureException(error));
- })
- .catch((error: unknown) => captureException(error));
- span.setStatus({ code: SPAN_STATUS_OK });
- },
+ });
+ if (credential && credential.cards.length > 0) {
+ const card = credential.cards[0];
+ span.setAttribute("exa.card", card?.id);
+ if (card?.mode === 0) {
+ await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
+ span.setAttribute("exa.mode", 1);
+ sendPushNotification({
+ userId: account,
+ headings: { en: "Card mode changed" },
+ contents: { en: "Credit mode activated" },
+ }).catch((error: unknown) => captureException(error));
+ }
+ }
+ }
+ span.setStatus({ code: SPAN_STATUS_OK });
+ } catch (error: unknown) {
+ captureException(error);
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: "autoCredit_failed" });
+ }
+ },
+ ),
),
),
),
- ),
)
.then((results) => {
let status: SpanStatus = { code: SPAN_STATUS_OK };
diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts
index a3f0c130a..3ca82abdc 100644
--- a/server/hooks/persona.ts
+++ b/server/hooks/persona.ts
@@ -20,16 +20,25 @@ import {
union,
} from "valibot";
+import { firewallAbi, firewallAddress } from "@exactly/common/generated/chain";
import { Address } from "@exactly/common/validation";
import database, { credentials } from "../database/index";
+import allower from "../utils/allower";
import { createUser } from "../utils/panda";
import { addCapita, deriveAssociateId } from "../utils/pax";
-import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona";
+import {
+ addDocument,
+ headerValidator,
+ MANTECA_TEMPLATE_WITH_ID_CLASS,
+ PANDA_TEMPLATE,
+ pokeAccountAssets,
+} from "../utils/persona";
import { customer } from "../utils/sardine";
import validatorHook from "../utils/validatorHook";
import type { InferOutput } from "valibot";
+
const Session = pipe(
object({
type: literal("inquiry-session"),
@@ -273,6 +282,20 @@ export default new Hono().post(
if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200);
}
+ if (firewallAddress) {
+ try {
+ const allowerClient = await allower();
+ await allowerClient.exaSend(
+ { name: "exa.firewall", op: "exa.firewall", attributes: { account: credential.account, personaShareToken } },
+ { address: firewallAddress, functionName: "allow", args: [credential.account, true], abi: firewallAbi },
+ { ignore: [`AlreadyAllowed(${credential.account})`] },
+ );
+ } catch (error: unknown) {
+ captureException(error, { level: "error" });
+ return c.json({ code: "firewall error" }, 500);
+ }
+ }
+
// TODO implement error handling to return 200 if event should not be retried
const { id } = await createUser({
accountPurpose: fields.accountPurpose.value,
@@ -289,8 +312,8 @@ export default new Hono().post(
getActiveSpan()?.setAttributes({ "exa.pandaId": id });
setContext("persona", { inquiryId: personaShareToken, pandaId: id });
- const account = safeParse(Address, credential.account);
- if (account.success) {
+ const accountParsed = safeParse(Address, credential.account);
+ if (accountParsed.success) {
addCapita({
birthdate: fields.birthdate.value,
document: fields.identificationNumber.value,
@@ -298,7 +321,7 @@ export default new Hono().post(
lastName: fields.nameLast.value,
email: fields.emailAddress.value,
phone: fields.phoneNumber?.value ?? "",
- internalId: deriveAssociateId(account.output),
+ internalId: deriveAssociateId(accountParsed.output),
product: "travel insurance",
}).catch((error: unknown) => {
captureException(error, { level: "error", extra: { pandaId: id, referenceId } });
@@ -309,6 +332,15 @@ export default new Hono().post(
level: "error",
});
}
+
+ if (accountParsed.success) {
+ pokeAccountAssets(accountParsed.output, {
+ notification: {
+ headings: { en: "Account assets updated" },
+ contents: { en: "Your funds are ready to use" },
+ },
+ }).catch(captureException);
+ }
addDocument(referenceId, {
id_class: { value: fields.identificationClass.value },
id_number: { value: fields.identificationNumber.value },
diff --git a/server/index.ts b/server/index.ts
index 84e4e02c7..216520204 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,3 +1,5 @@
+import "./utils/gcp";
+
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { captureException, close as closeSentry } from "@sentry/node";
diff --git a/server/package.json b/server/package.json
index 064cd4001..14c5375c8 100644
--- a/server/package.json
+++ b/server/package.json
@@ -32,6 +32,7 @@
"dependencies": {
"@account-kit/infra": "catalog:",
"@exactly/lib": "^0.1.0",
+ "@google-cloud/kms": "^5.3.0",
"@hono/node-server": "^1.19.9",
"@hono/sentry": "^1.2.2",
"@hono/valibot-validator": "^0.5.3",
@@ -43,6 +44,7 @@
"@simplewebauthn/server": "^13.2.2",
"@types/debug": "^4.1.12",
"@valibot/to-json-schema": "^1.5.0",
+ "@valora/viem-account-hsm-gcp": "^1.2.16",
"async-mutex": "^0.5.0",
"bullmq": "^5.66.5",
"debug": "^4.4.3",
diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts
index 2846d45a5..f44568521 100644
--- a/server/test/hooks/activity.test.ts
+++ b/server/test/hooks/activity.test.ts
@@ -8,7 +8,6 @@ import { captureException } from "@sentry/node";
import { testClient } from "hono/testing";
import {
bytesToHex,
- hexToBigInt,
hexToBytes,
padHex,
parseEther,
@@ -21,12 +20,10 @@ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest";
import deriveAddress from "@exactly/common/deriveAddress";
-import { exaAccountFactoryAbi, previewerAbi } from "@exactly/common/generated/chain";
import database, { credentials } from "../../database";
import app from "../../hooks/activity";
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";
@@ -106,142 +103,6 @@ describe("address activity", () => {
expect(response.status).toBe(200);
});
- it("pokes eth", async () => {
- const deposit = parseEther("5");
- await anvilClient.setBalance({ address: account, value: deposit });
-
- const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt");
-
- 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(
- () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 2,
- 26_666,
- );
-
- const exactly = await publicClient.readContract({
- address: inject("Previewer"),
- functionName: "exactly",
- abi: previewerAbi,
- args: [account],
- });
-
- const market = exactly.find((m) => m.asset === inject("WETH"));
-
- expect(market?.floatingDepositAssets).toBe(deposit);
- expect(market?.isCollateral).toBe(true);
- expect(response.status).toBe(200);
- });
-
- it("pokes weth and eth", async () => {
- const eth = parseEther("5");
- await anvilClient.setBalance({ address: account, value: eth });
-
- const weth = parseEther("2");
- await keeper.exaSend(
- { name: "mint", op: "tx.mint" },
- { address: inject("WETH"), abi: mockERC20Abi, functionName: "mint", args: [account, weth] },
- );
-
- const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt");
-
- const response = await appClient.index.$post({
- ...activityPayload,
- json: {
- ...activityPayload.json,
- event: {
- ...activityPayload.json.event,
- activity: [
- { ...activityPayload.json.event.activity[0], toAddress: account },
- {
- ...activityPayload.json.event.activity[1],
- toAddress: account,
- rawContract: { ...activityPayload.json.event.activity[1].rawContract, address: inject("WETH") },
- },
- ],
- },
- },
- });
-
- await vi.waitUntil(
- () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 2,
- 26_666,
- );
-
- const exactly = await publicClient.readContract({
- address: inject("Previewer"),
- functionName: "exactly",
- abi: previewerAbi,
- args: [account],
- });
-
- const market = exactly.find((m) => m.asset === inject("WETH"));
-
- expect(market?.floatingDepositAssets).toBe(eth + weth);
- expect(market?.isCollateral).toBe(true);
- expect(response.status).toBe(200);
- });
-
- it("pokes multiple accounts", async () => {
- const owners = [
- owner,
- privateKeyToAccount(generatePrivateKey()),
- privateKeyToAccount(generatePrivateKey()),
- ] as const;
- const accounts = owners.map(({ address }) =>
- deriveAddress(inject("ExaAccountFactory"), { x: padHex(address), y: zeroHash }),
- );
- await Promise.all([
- ...accounts.slice(1).map((id) =>
- database.insert(credentials).values({
- id,
- publicKey: new Uint8Array(hexToBytes(id)),
- account: id,
- factory: inject("ExaAccountFactory"),
- }),
- ),
- ...accounts.map((address) => anvilClient.setBalance({ address, value: parseEther("5") })),
- keeper.exaSend(
- { name: "create account", op: "exa.account" },
- {
- address: inject("ExaAccountFactory"),
- abi: exaAccountFactoryAbi,
- functionName: "createAccount",
- args: [0n, [{ x: hexToBigInt(owners[0].address), y: 0n }]],
- },
- ),
- ]);
-
- const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt");
- const [response] = await Promise.all([
- appClient.index.$post({
- ...activityPayload,
- json: {
- ...activityPayload.json,
- event: {
- ...activityPayload.json.event,
- activity: accounts.map((toAddress) => ({ ...activityPayload.json.event.activity[0], toAddress })),
- },
- },
- }),
- vi.waitUntil(
- () => waitForTransactionReceipt.mock.settledResults.filter(({ type }) => type !== "incomplete").length >= 5,
- 26_666,
- ),
- ]);
-
- expect(response.status).toBe(200);
- });
-
it("deploy account for non market asset", async () => {
const waitForTransactionReceipt = vi.spyOn(publicClient, "waitForTransactionReceipt");
@@ -346,13 +207,3 @@ afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
-
-const mockERC20Abi = [
- {
- type: "function",
- name: "mint",
- inputs: [{ type: "address" }, { type: "uint256" }],
- outputs: [],
- stateMutability: "nonpayable",
- },
-] as const;
diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts
index 6c3774e02..a41670b51 100644
--- a/server/test/hooks/persona.test.ts
+++ b/server/test/hooks/persona.test.ts
@@ -5,22 +5,41 @@ import "../mocks/sentry";
import { captureException } from "@sentry/node";
import { eq } from "drizzle-orm";
import { testClient } from "hono/testing";
-import { hexToBytes, padHex, zeroHash } from "viem";
+import { hexToBytes, padHex, parseEther, zeroHash } from "viem";
import { privateKeyToAddress } from "viem/accounts";
import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest";
import deriveAddress from "@exactly/common/deriveAddress";
+import { wethAddress } from "@exactly/common/generated/chain";
import database, { credentials } from "../../database";
import app from "../../hooks/persona";
+import keeper from "../../utils/keeper";
import * as panda from "../../utils/panda";
import * as pax from "../../utils/pax";
import * as persona from "../../utils/persona";
+import publicClient from "../../utils/publicClient";
import * as sardine from "../../utils/sardine";
const appClient = testClient(app);
vi.mock("@sentry/node", { spy: true });
+const mockExaSend = vi.fn().mockResolvedValue({});
+
+vi.mock("../../utils/allower", () => ({
+ default: vi.fn(() =>
+ Promise.resolve({
+ exaSend: mockExaSend,
+ }),
+ ),
+}));
+vi.mock("@exactly/common/generated/chain", async () => {
+ const actual = await vi.importActual("@exactly/common/generated/chain");
+ return {
+ ...actual,
+ firewallAddress: "0x1234567890123456789012345678901234567890",
+ };
+});
describe("with reference", () => {
const referenceId = "hook-persona";
@@ -381,7 +400,11 @@ describe("persona hook", () => {
});
});
- afterEach(() => vi.resetAllMocks());
+ afterEach(async () => {
+ // reset pandaId to null for next test
+ 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 +412,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 +453,285 @@ 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 exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never);
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ]) // assets from exaPreviewerAddress
+ .mockResolvedValueOnce(parseEther("2")); // balanceOf for the asset
+
+ 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(() => exaSendSpy.mock.calls.length >= 2, { timeout: 5000 });
+
+ expect(exaSendSpy).toHaveBeenNthCalledWith(
+ 1,
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" },
+ },
+ {
+ address: account,
+ abi: expect.any(Array) as unknown[],
+ functionName: "pokeETH",
+ },
+ );
+ expect(exaSendSpy).toHaveBeenNthCalledWith(
+ 2,
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account, asset: "0x1234567890123456789012345678901234567890" },
+ },
+ {
+ address: account,
+ abi: expect.any(Array) as unknown[],
+ functionName: "poke",
+ args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"],
+ },
+ );
+ });
+
+ it("pokes only eth when balance is positive", async () => {
+ const account = deriveAddress(inject("ExaAccountFactory"), {
+ x: padHex(privateKeyToAddress(padHex("0x420"))),
+ y: zeroHash,
+ });
+ const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never);
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([{ asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }]) // assets from exaPreviewerAddress
+ .mockResolvedValueOnce(0n); // balanceOf for WETH is 0
+
+ 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(() => exaSendSpy.mock.calls.length > 0, { timeout: 5000 });
+
+ expect(exaSendSpy).toHaveBeenCalledTimes(1);
+ expect(exaSendSpy).toHaveBeenCalledWith(
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" },
+ },
+ {
+ address: account,
+ abi: expect.any(Array) as unknown[],
+ functionName: "pokeETH",
+ },
+ );
+ });
+
+ it("skips weth when eth balance is positive", async () => {
+ const account = deriveAddress(inject("ExaAccountFactory"), {
+ x: padHex(privateKeyToAddress(padHex("0x420"))),
+ y: zeroHash,
+ });
+ const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never);
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ]) // assets from exaPreviewerAddress
+ .mockResolvedValueOnce(parseEther("5")) // balanceOf for WETH
+ .mockResolvedValueOnce(parseEther("2")); // balanceOf for other asset
+
+ 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(() => exaSendSpy.mock.calls.length >= 2, { timeout: 5000 });
+
+ // should poke ETH and the other asset, but skip WETH
+ expect(exaSendSpy).toHaveBeenCalledTimes(2);
+ expect(exaSendSpy).toHaveBeenCalledWith(
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account, asset: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" },
+ },
+ {
+ address: account,
+ abi: expect.any(Array) as unknown[],
+ functionName: "pokeETH",
+ },
+ );
+ expect(exaSendSpy).toHaveBeenCalledWith(
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account, asset: "0x1234567890123456789012345678901234567890" },
+ },
+ {
+ address: account,
+ abi: expect.any(Array) as unknown[],
+ functionName: "poke",
+ args: ["0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD"],
+ },
+ );
+ });
+
+ it("does not poke when balances are zero", async () => {
+ const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue({} as never);
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ]) // assets from exaPreviewerAddress
+ .mockResolvedValueOnce(0n); // balanceOf is 0
+
+ vi.spyOn(publicClient, "getBalance").mockResolvedValue(0n); // ETH balance is 0
+
+ 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);
+
+ // use vi.waitFor to ensure no pokes happen with a reasonable timeout
+ // the wait is necessary because pokeAccountAssets is called asynchronously
+ await vi.waitFor(
+ () => {
+ expect(exaSendSpy).not.toHaveBeenCalled();
+ },
+ { timeout: 500, interval: 50 },
+ );
+ });
+
+ 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" });
+
+ mockExaSend.mockRejectedValueOnce(new Error("Firewall error"));
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ code: "firewall error" });
+ });
});
describe("manteca template", () => {
@@ -447,6 +751,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/allower.test.ts b/server/test/utils/allower.test.ts
new file mode 100644
index 000000000..71c54d57e
--- /dev/null
+++ b/server/test/utils/allower.test.ts
@@ -0,0 +1,90 @@
+import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp";
+import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getAccount } from "../../utils/allower";
+
+import type * as accounts from "viem/accounts";
+
+function mockGcp() {
+ return {
+ GOOGLE_APPLICATION_CREDENTIALS: "/tmp/gcp-service-account.json",
+ hasCredentials: vi.fn().mockResolvedValue(true),
+ initializeGcpCredentials: vi.fn().mockImplementation(() => Promise.resolve()),
+ validateGcpConfiguration: vi.fn(),
+ isRetryableKmsError: vi.fn().mockReturnValue(false),
+ trackKmsOperation: vi.fn(),
+ };
+}
+
+function mockViemHsm() {
+ return {
+ gcpHsmToAccount: vi.fn().mockResolvedValue({ address: "0xGCPAccount", source: "gcpHsm", type: "local" }),
+ };
+}
+
+async function mockViemAccounts(importOriginal: () => Promise) {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ privateKeyToAccount: vi.fn(actual.privateKeyToAccount),
+ };
+}
+
+function mockKms() {
+ return {
+ KeyManagementServiceClient: vi.fn(function MockKeyManagementServiceClient() {
+ return {};
+ }),
+ };
+}
+
+vi.mock("../../utils/gcp", mockGcp);
+
+vi.mock("@valora/viem-account-hsm-gcp", mockViemHsm);
+
+vi.mock("viem/accounts", mockViemAccounts);
+
+vi.mock("@google-cloud/kms", mockKms);
+
+describe("getAccount", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // cspell:ignore unstub
+ vi.unstubAllEnvs();
+ });
+
+ afterEach(() => {
+ // cspell:ignore unstub
+ vi.unstubAllEnvs();
+ });
+
+ it("uses Private Key account when GCP_PROJECT_ID is missing", async () => {
+ const privateKey = generatePrivateKey();
+ vi.stubEnv("GCP_PROJECT_ID", "");
+ vi.stubEnv("KEEPER_PRIVATE_KEY", privateKey);
+
+ const account = await getAccount();
+
+ expect(gcpHsmToAccount).not.toHaveBeenCalled();
+ expect(privateKeyToAccount).toHaveBeenCalled();
+ expect(account.address).toBe(privateKeyToAccount(privateKey).address);
+ expect(account.nonceManager).toBeDefined();
+ });
+
+ it("uses GCP HSM account when GCP_PROJECT_ID is present", async () => {
+ vi.stubEnv("GCP_PROJECT_ID", "test-project");
+ vi.stubEnv("GCP_KMS_KEY_RING", "test-ring");
+ vi.stubEnv("GCP_KMS_KEY_VERSION", "1");
+
+ const account = await getAccount();
+
+ expect(gcpHsmToAccount).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hsmKeyVersion:
+ "projects/test-project/locations/us-west2/keyRings/test-ring/cryptoKeys/allower/cryptoKeyVersions/1",
+ }),
+ );
+ expect(account.nonceManager).toBeDefined();
+ });
+});
diff --git a/server/test/utils/gcp.test.ts b/server/test/utils/gcp.test.ts
new file mode 100644
index 000000000..73f448631
--- /dev/null
+++ b/server/test/utils/gcp.test.ts
@@ -0,0 +1,50 @@
+import { access, writeFile } from "node:fs/promises";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { initializeGcpCredentials, resetGcpInitialization } from "../../utils/gcp";
+
+vi.mock("node:fs/promises", () => ({
+ writeFile: vi.fn(),
+ access: vi.fn(),
+}));
+
+const mockWriteFile = vi.mocked(writeFile);
+const mockAccess = vi.mocked(access);
+
+describe("gcp credentials security", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // cspell:ignore unstub
+ vi.unstubAllEnvs();
+ resetGcpInitialization();
+ mockAccess.mockRejectedValue(new Error("File not found"));
+ });
+
+ it("creates credentials file with secure permissions (0o600)", async () => {
+ vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K");
+
+ await initializeGcpCredentials();
+
+ expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), {
+ mode: 0o600,
+ });
+ });
+
+ it("throws error when GCP_PROJECT_ID is set but GCP_BASE64_JSON is missing", async () => {
+ vi.stubEnv("GCP_PROJECT_ID", "test-project");
+ vi.stubEnv("GCP_BASE64_JSON", "");
+
+ await expect(initializeGcpCredentials()).rejects.toThrow(
+ "gcp project configured but GCP_BASE64_JSON environment variable is not set",
+ );
+ });
+
+ it("returns early when credentials already exist", async () => {
+ vi.stubEnv("GCP_BASE64_JSON", "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K");
+ mockAccess.mockResolvedValue();
+
+ await initializeGcpCredentials();
+
+ expect(mockWriteFile).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/test/utils/manteca.test.ts b/server/test/utils/manteca.test.ts
index 7eec89575..688ae2cf5 100644
--- a/server/test/utils/manteca.test.ts
+++ b/server/test/utils/manteca.test.ts
@@ -11,7 +11,14 @@ import * as persona from "../../utils/persona";
import * as manteca from "../../utils/ramps/manteca";
import { ErrorCodes } from "../../utils/ramps/manteca";
-const chainMock = vi.hoisted(() => ({ id: 10 }));
+const chainMock = vi.hoisted(() => ({
+ id: 10,
+ rpcUrls: {
+ alchemy: {
+ http: ["https://mocked-rpc-url"],
+ },
+ },
+}));
vi.mock("@exactly/common/generated/chain", () => ({
default: chainMock,
diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts
index c531fbb12..82e8c397d 100644
--- a/server/test/utils/persona.test.ts
+++ b/server/test/utils/persona.test.ts
@@ -7,7 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
import * as persona from "../../utils/persona";
-const chainMock = vi.hoisted(() => ({ id: 10 }));
+const chainMock = vi.hoisted(() => ({
+ id: 10,
+ rpcUrls: {
+ alchemy: {
+ http: ["https://mocked-rpc-url"],
+ },
+ },
+}));
vi.mock("@exactly/common/generated/chain", () => ({
default: chainMock,
diff --git a/server/utils/allower.ts b/server/utils/allower.ts
new file mode 100644
index 000000000..1e545b1f2
--- /dev/null
+++ b/server/utils/allower.ts
@@ -0,0 +1,104 @@
+import { KeyManagementServiceClient } from "@google-cloud/kms";
+import { captureMessage } from "@sentry/node";
+import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp";
+import { parse } from "valibot";
+import { createWalletClient, http, withRetry } from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+
+import alchemyAPIKey from "@exactly/common/alchemyAPIKey";
+import chain from "@exactly/common/generated/chain";
+import { Hash } from "@exactly/common/validation";
+
+import {
+ GOOGLE_APPLICATION_CREDENTIALS,
+ hasCredentials,
+ initializeGcpCredentials,
+ isRetryableKmsError,
+ trackKmsOperation,
+ validateGcpConfiguration,
+} from "./gcp";
+import { extender } from "./keeper";
+import nonceManager from "./nonceManager";
+import { captureRequests, Requests } from "./publicClient";
+
+import type { LocalAccount } from "viem";
+
+const gcpKmsKeyName = "allower";
+
+if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url");
+const rpcUrl = chain.rpcUrls.alchemy.http[0];
+
+validateGcpConfiguration();
+
+export async function getAccount(): Promise {
+ if (process.env.GCP_PROJECT_ID) {
+ const projectId = process.env.GCP_PROJECT_ID;
+
+ if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("missing gcp kms key version");
+ const version = process.env.GCP_KMS_KEY_VERSION;
+
+ if (!process.env.GCP_KMS_KEY_RING) throw new Error("missing gcp kms key ring");
+ const gcpKmsKeyRing = process.env.GCP_KMS_KEY_RING;
+
+ 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.`,
+ );
+ }
+
+ const kmsClient = new KeyManagementServiceClient({
+ keyFilename: GOOGLE_APPLICATION_CREDENTIALS,
+ });
+
+ try {
+ const account = await withRetry(
+ () =>
+ gcpHsmToAccount({
+ hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${gcpKmsKeyRing}/cryptoKeys/${gcpKmsKeyName}/cryptoKeyVersions/${version}`,
+ kmsClient,
+ }),
+ {
+ delay: 2000,
+ retryCount: 3,
+ shouldRetry: ({ error }) => isRetryableKmsError(error),
+ },
+ );
+
+ trackKmsOperation("get_account", true);
+ account.nonceManager = nonceManager;
+ return account;
+ } catch (error) {
+ trackKmsOperation("get_account", false, error);
+ throw error;
+ }
+ } else {
+ if (!process.env.KEEPER_PRIVATE_KEY) throw new Error("missing keeper private key");
+ return privateKeyToAccount(parse(Hash, process.env.KEEPER_PRIVATE_KEY, { message: "invalid keeper private key" }), {
+ nonceManager,
+ });
+ }
+}
+
+export default async function createAllower() {
+ const account = await getAccount();
+ return createWalletClient({
+ chain,
+ transport: http(`${rpcUrl}/${alchemyAPIKey}`, {
+ batch: true,
+ async onFetchRequest(request) {
+ try {
+ captureRequests(parse(Requests, await request.clone().json()));
+ } catch (error: unknown) {
+ captureMessage("failed to parse or capture rpc requests", {
+ level: "error",
+ extra: { error },
+ });
+ }
+ },
+ }),
+ account,
+ }).extend(extender);
+}
diff --git a/server/utils/gcp.ts b/server/utils/gcp.ts
new file mode 100644
index 000000000..dbde645c1
--- /dev/null
+++ b/server/utils/gcp.ts
@@ -0,0 +1,143 @@
+import { captureException, captureMessage, withScope } from "@sentry/node";
+import { access, writeFile } from "node:fs/promises";
+
+// tokens/credentials are base64-encoded multiple times by deployment tooling
+const DECODING_ITERATIONS = 3;
+export const GOOGLE_APPLICATION_CREDENTIALS = "/tmp/gcp-service-account.json";
+
+// this file is necessary because of limitations at runtime to mount volumes to reference
+// the GOOGLE_APPLICATION_CREDENTIALS environment variable. we encode the service account's contents
+// into a variable and dump those contents to the path set in GOOGLE_APPLICATION_CREDENTIALS
+// so the loading can work normally. this will ensure consistency across different environments.
+let initializationPromise: null | Promise = null;
+
+// for testing only - reset the initialization state
+export function resetGcpInitialization() {
+ initializationPromise = null;
+}
+
+export async function initializeGcpCredentials() {
+ if (initializationPromise) {
+ return initializationPromise;
+ }
+
+ initializationPromise = (async () => {
+ if (await hasCredentials()) {
+ return;
+ }
+
+ if (process.env.GCP_BASE64_JSON) {
+ let json = process.env.GCP_BASE64_JSON;
+ for (let index = 0; index < DECODING_ITERATIONS; index++) {
+ json = Buffer.from(json, "base64").toString("utf8");
+ }
+ await writeFile(GOOGLE_APPLICATION_CREDENTIALS, json, { mode: 0o600 });
+ } else if (process.env.GCP_PROJECT_ID) {
+ throw new Error(
+ "gcp project configured but GCP_BASE64_JSON environment variable is not set. " +
+ "this is required to initialize gcp kms credentials.",
+ );
+ }
+ })().catch((error: unknown) => {
+ initializationPromise = null;
+ throw error;
+ });
+
+ return initializationPromise;
+}
+
+export async function hasCredentials(): Promise {
+ try {
+ await access(GOOGLE_APPLICATION_CREDENTIALS);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function validateGcpConfiguration() {
+ if (!process.env.GCP_PROJECT_ID) return;
+
+ const errors: string[] = [];
+
+ if (!process.env.GCP_KMS_KEY_RING) {
+ errors.push("GCP_KMS_KEY_RING is required when using GCP KMS");
+ }
+
+ if (!process.env.GCP_KMS_KEY_VERSION) {
+ errors.push("GCP_KMS_KEY_VERSION is required when using GCP KMS");
+ }
+
+ if (!process.env.GCP_BASE64_JSON) {
+ errors.push("GCP_BASE64_JSON is required when using GCP KMS");
+ }
+
+ if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(process.env.GCP_PROJECT_ID)) {
+ errors.push("GCP_PROJECT_ID must be a valid GCP project ID format");
+ }
+
+ if (process.env.GCP_KMS_KEY_VERSION && !/^\d+$/.test(process.env.GCP_KMS_KEY_VERSION)) {
+ errors.push("GCP_KMS_KEY_VERSION must be a numeric version number");
+ }
+
+ if (errors.length > 0) {
+ throw new Error(`GCP KMS configuration errors:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
+ }
+}
+
+export function isRetryableKmsError(error: unknown): boolean {
+ if (error instanceof Error) {
+ if ("code" in error && typeof error.code === "number") {
+ return (
+ error.code === 14 || // UNAVAILABLE
+ error.code === 4 || // DEADLINE_EXCEEDED
+ error.code === 13 || // INTERNAL
+ error.code === 8 // RESOURCE_EXHAUSTED
+ );
+ }
+
+ if ("code" in error && typeof error.code === "string") {
+ return (
+ error.code === "UNAVAILABLE" ||
+ error.code === "DEADLINE_EXCEEDED" ||
+ error.code === "INTERNAL" ||
+ error.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"
+ );
+ }
+ return false;
+}
+
+export function trackKmsOperation(operation: string, success: boolean, error?: unknown) {
+ withScope((scope) => {
+ scope.setTag("kms.operation.type", operation);
+ scope.setTag("kms.operation.success", String(success));
+
+ if (success) {
+ scope.setTag("kms.operation.result", "success");
+ } else {
+ scope.setTag("kms.operation.result", "failure");
+ if (error instanceof Error) {
+ captureException(error, {
+ level: "error",
+ });
+ } else {
+ captureMessage(String(error), {
+ level: "error",
+ extra: { originalError: error },
+ });
+ }
+ }
+ });
+}
diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts
index 8bb936962..8878144c6 100644
--- a/server/utils/keeper.ts
+++ b/server/utils/keeper.ts
@@ -15,9 +15,9 @@ import {
WaitForTransactionReceiptTimeoutError,
withRetry,
type HttpTransport,
+ type LocalAccount,
type MaybePromise,
type Prettify,
- type PrivateKeyAccount,
type TransactionReceipt,
type WalletClient,
type WriteContractParameters,
@@ -50,7 +50,7 @@ export default createWalletClient({
),
}).extend(extender);
-export function extender(keeper: WalletClient) {
+export function extender(keeper: WalletClient) {
return {
exaSend: async (
spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>,
diff --git a/server/utils/persona.ts b/server/utils/persona.ts
index 578274928..958520966 100644
--- a/server/utils/persona.ts
+++ b/server/utils/persona.ts
@@ -1,6 +1,7 @@
import { vValidator } from "@hono/valibot-validator";
-import { captureEvent, setContext } from "@sentry/core";
+import { captureEvent, captureException, setContext } from "@sentry/core";
import { createHmac, timingSafeEqual } from "node:crypto";
+import * as v from "valibot";
import {
array,
boolean,
@@ -17,10 +18,23 @@ import {
type BaseSchema,
type InferOutput,
} from "valibot";
-
-import chain from "@exactly/common/generated/chain";
+import { erc20Abi, withRetry } from "viem";
+
+import chain, {
+ auditorAbi,
+ exaPluginAbi,
+ exaPreviewerAbi,
+ exaPreviewerAddress,
+ marketAbi,
+ upgradeableModularAccountAbi,
+ wethAddress,
+} from "@exactly/common/generated/chain";
+import { Address } from "@exactly/common/validation";
import appOrigin from "./appOrigin";
+import keeper from "./keeper";
+import { sendPushNotification } from "./onesignal";
+import publicClient from "./publicClient";
import { DevelopmentChainIds } from "./ramps/shared";
if (!process.env.PERSONA_API_KEY) throw new Error("missing persona api key");
@@ -38,6 +52,125 @@ const authorization = `Bearer ${process.env.PERSONA_API_KEY}`;
const baseURL = process.env.PERSONA_URL;
const webhookSecret = process.env.PERSONA_WEBHOOK_SECRET;
+export async function pokeAccountAssets(
+ accountAddress: Address,
+ options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } },
+) {
+ const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi];
+ const marketsByAsset = await withRetry(
+ () => publicClient.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }),
+ {
+ delay: 2000,
+ retryCount: 5,
+ shouldRetry: ({ error }) => {
+ captureException(error, { level: "error" });
+ return true;
+ },
+ },
+ ).then((p) => new Map(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)])));
+ const WETH = v.parse(Address, wethAddress);
+ const ETH = v.parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
+
+ const assetsToPoke: { asset: Address; market: Address | null }[] = [];
+
+ const [ethBalance, assetBalances] = await Promise.all([
+ withRetry(() => publicClient.getBalance({ address: accountAddress }), {
+ delay: 2000,
+ retryCount: 5,
+ shouldRetry: ({ error }) => {
+ captureException(error, { level: "error" });
+ return true;
+ },
+ }),
+ Promise.all(
+ [...marketsByAsset.entries()].map(async ([asset, market]) => {
+ const maxAttempts = 3;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ const balance = await publicClient.readContract({
+ address: asset,
+ functionName: "balanceOf",
+ args: [accountAddress],
+ abi: erc20Abi,
+ });
+ return { asset, market, balance };
+ } catch (error) {
+ captureException(error, { level: "error" });
+ if (attempt === maxAttempts) {
+ return { asset, market, balance: 0n };
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
+ }
+ }
+ return { asset, market, balance: 0n };
+ }),
+ ),
+ ]);
+
+ const hasETH = ethBalance > 0n;
+
+ if (hasETH) {
+ assetsToPoke.push({ asset: ETH, market: null });
+ }
+
+ for (const { asset, market, balance } of assetBalances) {
+ if (hasETH && asset === WETH) continue;
+
+ if (balance > 0n) {
+ assetsToPoke.push({ asset, market });
+ }
+ }
+
+ const pokePromises = assetsToPoke.map(({ asset, market }) =>
+ withRetry(
+ () =>
+ keeper.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 }] : []),
+ ),
+ {
+ delay: 2000,
+ retryCount: 5,
+ shouldRetry: ({ error }) => {
+ captureException(error, { level: "error" });
+ return true;
+ },
+ },
+ ),
+ );
+
+ const results = await Promise.allSettled(pokePromises);
+ for (const result of results) {
+ if (result.status === "rejected") captureException(result.reason);
+ }
+
+ const successCount = results.filter((result) => result.status === "fulfilled").length;
+
+ if (options?.notification && successCount > 0) {
+ sendPushNotification({
+ userId: accountAddress,
+ headings: options.notification.headings,
+ contents: options.notification.contents,
+ }).catch((error: unknown) => captureException(error));
+ }
+}
+
export async function getInquiry(referenceId: string, templateId: string) {
const { data: approvedInquiries } = await request(
GetInquiriesResponse,
diff --git a/server/vitest.config.mts b/server/vitest.config.mts
index eaae9e1b4..831d43012 100644
--- a/server/vitest.config.mts
+++ b/server/vitest.config.mts
@@ -52,6 +52,10 @@ VuNOZKwaXFtqgA==
SARDINE_API_KEY: "sardine",
SARDINE_API_URL: "https://api.sardine.ai",
SEGMENT_WRITE_KEY: "segment",
+ GCP_KMS_KEY_RING: "op-sepolia",
+ GCP_KMS_KEY_VERSION: "1",
+ GCP_PROJECT_ID: "exa-dev",
+ GCP_BASE64_JSON: "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K",
...(env.NODE_ENV === "e2e" && { APP_DOMAIN: "localhost", DEBUG: "exa:*" }),
},
...(env.NODE_ENV === "e2e" && {
diff --git a/src/utils/persona.ts b/src/utils/persona.ts
index 17439e97a..fc5e6a3fa 100644
--- a/src/utils/persona.ts
+++ b/src/utils/persona.ts
@@ -58,7 +58,7 @@ export function startKYC() {
handleCancel();
resolve();
},
- onError: (error) => {
+ onError: (error: unknown) => {
signal.removeEventListener("abort", onAbort);
client.destroy();
reportError(error);