diff --git a/.changeset/lucky-jokes-change.md b/.changeset/lucky-jokes-change.md
new file mode 100644
index 000000000..ec7d16fad
--- /dev/null
+++ b/.changeset/lucky-jokes-change.md
@@ -0,0 +1,5 @@
+---
+"@exactly/server": patch
+---
+
+✨ use gcp kms for allower
diff --git a/.changeset/silly-yaks-divide.md b/.changeset/silly-yaks-divide.md
new file mode 100644
index 000000000..d3f7700c5
--- /dev/null
+++ b/.changeset/silly-yaks-divide.md
@@ -0,0 +1,5 @@
+---
+"@exactly/server": patch
+---
+
+✨ poke account after kyc
diff --git a/.do/app.yaml b/.do/app.yaml
index a82ea1324..518d88eff 100644
--- a/.do/app.yaml
+++ b/.do/app.yaml
@@ -84,6 +84,19 @@ services:
- key: DEBUG
scope: RUN_TIME
value: ${{ env.DEBUG }}
+ - key: GCP_KMS_KEY_RING
+ scope: RUN_TIME
+ value: ${{ env.GCP_KMS_KEY_RING }}
+ - key: GCP_KMS_KEY_VERSION
+ scope: RUN_TIME
+ value: ${{ env.GCP_KMS_KEY_VERSION }}
+ - key: GCP_PROJECT_ID
+ scope: RUN_TIME
+ value: ${{ env.GCP_PROJECT_ID }}
+ - key: GCP_BASE64_JSON
+ scope: RUN_TIME
+ type: SECRET
+ value: ${{ env.ENCRYPTED_GCP_BASE64_JSON || env.GCP_BASE64_JSON }}
- key: INTERCOM_IDENTITY_KEY
scope: RUN_TIME
type: SECRET
diff --git a/cspell.json b/cspell.json
index 2801f5c87..82f5554f6 100644
--- a/cspell.json
+++ b/cspell.json
@@ -176,6 +176,7 @@
"valibot",
"valierror",
"valkey",
+ "valora",
"viem",
"viewability",
"wagmi",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 85e508d08..daa7e41d7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -728,6 +728,9 @@ importers:
'@exactly/lib':
specifier: ^0.1.0
version: 0.1.0
+ '@google-cloud/kms':
+ specifier: ^5.3.0
+ version: 5.4.0
'@hono/node-server':
specifier: ^1.19.11
version: 1.19.11(hono@4.12.9)
@@ -764,6 +767,9 @@ importers:
'@valibot/to-json-schema':
specifier: ^1.6.0
version: 1.6.0(valibot@1.3.1(typescript@5.9.3))
+ '@valora/viem-account-hsm-gcp':
+ specifier: ^1.2.16
+ version: 1.2.17(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@@ -3077,6 +3083,19 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+ '@google-cloud/kms@5.4.0':
+ resolution: {integrity: sha512-+06zUCaJM+wyZISM3F6u/jSqoBs0iZ8Aj9rqOJFePoWkNN7FbR4mQpV7okGHA+Y7caVgq+4QtIDKiFd17SZT+A==}
+ engines: {node: '>=18'}
+
+ '@grpc/grpc-js@1.14.3':
+ resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
+ engines: {node: '>=12.10.0'}
+
+ '@grpc/proto-loader@0.8.0':
+ resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
+ engines: {node: '>=6'}
+ hasBin: true
+
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
engines: {node: '>=14.0.0'}
@@ -3534,6 +3553,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@js-sdsl/ordered-map@4.4.2':
+ resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
+
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
@@ -4132,6 +4154,36 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.8
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.4':
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ '@protobufjs/eventemitter@1.1.0':
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ '@protobufjs/fetch@1.1.0':
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/inquire@1.1.0':
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.0':
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -5828,6 +5880,10 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+ '@tootallnate/once@2.0.0':
+ resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
+ engines: {node: '>= 10'}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -6304,6 +6360,12 @@ packages:
peerDependencies:
valibot: ^1.3.0
+ '@valora/viem-account-hsm-gcp@1.2.17':
+ resolution: {integrity: sha512-xRQ6C9qIFqQi6JQYGFenQDeiK39RXLbEG+4/uiHqGQqsrTiELm+fOkjhbX4vC+E009zvb340GuSP5QPVPPmOIw==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ viem: ^2.9.20
+
'@vitest/coverage-v8@4.1.2':
resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==}
peerDependencies:
@@ -6468,6 +6530,10 @@ packages:
aes-js@3.0.0:
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -6864,6 +6930,9 @@ packages:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
+
birecord@0.1.1:
resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==}
@@ -6937,6 +7006,9 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -7610,6 +7682,10 @@ packages:
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+ data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -7929,9 +8005,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ duplexify@4.1.3:
+ resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
+
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
edit-json-file@1.8.1:
resolution: {integrity: sha512-x8L381+GwqxQejPipwrUZIyAg5gDQ9tLVwiETOspgXiaQztLsrOm7luBW5+Pe31aNezuzDY79YyzF+7viCRPXA==}
@@ -8844,6 +8926,10 @@ packages:
picomatch:
optional: true
+ fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+
fetch-nodeshim@0.4.10:
resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==}
@@ -8981,6 +9067,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
@@ -9050,6 +9140,14 @@ packages:
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
deprecated: This package is no longer supported.
+ gaxios@7.1.3:
+ resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@8.1.2:
+ resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
+ engines: {node: '>=18'}
+
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -9195,6 +9293,18 @@ packages:
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ google-auth-library@10.6.1:
+ resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==}
+ engines: {node: '>=18'}
+
+ google-gax@5.0.6:
+ resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@1.1.3:
+ resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
+ engines: {node: '>=14'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -9455,6 +9565,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ http-proxy-agent@5.0.0:
+ resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+ engines: {node: '>= 6'}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -9467,6 +9581,10 @@ packages:
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
engines: {node: '>=10.19.0'}
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -9975,6 +10093,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-bigint@1.0.0:
+ resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -10033,6 +10154,12 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
katex@0.16.44:
resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==}
hasBin: true
@@ -10214,6 +10341,9 @@ packages:
lodash._pickbycallback@3.0.0:
resolution: {integrity: sha512-DVP27YmN0lB+j/Tgd/+gtxfmW/XihgWpQpHptBuwyp2fD9zEBRwwcnw6Qej16LUV8LRFuTqyoc0i6ON97d/C5w==}
+ lodash.camelcase@4.3.0:
+ resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -10266,6 +10396,9 @@ packages:
resolution: {integrity: sha512-p1Ow0C2dDJYaQBhRHt+HVMP6ELuBm4jYSYNHPMfz0J5wJ9qA6/7oBOlBZBfT1InqguTYcvJzNea5FItDxTcbyw==}
hasBin: true
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -10940,6 +11073,11 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
+ node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
+
node-exports-info@1.6.0:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
@@ -10956,6 +11094,10 @@ packages:
encoding:
optional: true
+ node-fetch@3.3.2:
+ resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
node-forge@1.4.0:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
@@ -11045,6 +11187,10 @@ packages:
object-deep-merge@2.0.0:
resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==}
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -11567,6 +11713,14 @@ packages:
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
+ proto3-json-serializer@3.0.4:
+ resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==}
+ engines: {node: '>=18'}
+
+ protobufjs@7.5.4:
+ resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
+ engines: {node: '>=12.0.0'}
+
protolint@0.56.4:
resolution: {integrity: sha512-wrRXaiyNDSzYJ7LBcDnwkWnsRi1uNlFleQp90CsBsh2YvVJEwKXr/c/W9MRYdt+ScpEo8Eg3d60QmVhsZBJu2w==}
hasBin: true
@@ -12115,6 +12269,10 @@ packages:
retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
+ retry-request@8.0.2:
+ resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==}
+ engines: {node: '>=18'}
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -12509,9 +12667,15 @@ packages:
resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==}
engines: {node: '>= 0.10.0'}
+ stream-events@1.0.5:
+ resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==}
+
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
+ stream-shift@1.0.3:
+ resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
+
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
@@ -12595,6 +12759,9 @@ packages:
structured-headers@0.4.1:
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
+ stubs@3.0.0:
+ resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
+
sturdy-websocket@0.2.1:
resolution: {integrity: sha512-NnzSOEKyv4I83qbuKw9ROtJrrT6Z/Xt7I0HiP/e6H6GnpeTDvzwGIGeJ8slai+VwODSHQDooW2CAilJwT9SpRg==}
@@ -12690,6 +12857,10 @@ packages:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'}
+ teeny-request@10.1.0:
+ resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==}
+ engines: {node: '>=18'}
+
temp-dir@3.0.0:
resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==}
engines: {node: '>=14.16'}
@@ -13406,6 +13577,10 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+ web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+ engines: {node: '>= 8'}
+
webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda:
resolution: {tarball: https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda}
version: 0.0.0
@@ -16441,6 +16616,24 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
+ '@google-cloud/kms@5.4.0':
+ dependencies:
+ google-gax: 5.0.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@grpc/grpc-js@1.14.3':
+ dependencies:
+ '@grpc/proto-loader': 0.8.0
+ '@js-sdsl/ordered-map': 4.4.2
+
+ '@grpc/proto-loader@0.8.0':
+ dependencies:
+ lodash.camelcase: 4.3.0
+ long: 5.3.2
+ protobufjs: 7.5.4
+ yargs: 17.7.2
+
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -16808,6 +17001,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@js-sdsl/ordered-map@4.4.2': {}
+
'@jsdevtools/ono@7.1.3': {}
'@levischuck/tiny-cbor@0.2.11': {}
@@ -17664,6 +17859,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.4': {}
+
+ '@protobufjs/eventemitter@1.1.0': {}
+
+ '@protobufjs/fetch@1.1.0':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/inquire@1.1.0': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.0': {}
+
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
@@ -18358,7 +18576,7 @@ snapshots:
'@scure/bip32@1.7.0':
dependencies:
- '@noble/curves': 1.9.1
+ '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
@@ -20071,6 +20289,8 @@ snapshots:
'@tanstack/store@0.9.3': {}
+ '@tootallnate/once@2.0.0': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -20599,6 +20819,15 @@ snapshots:
dependencies:
valibot: 1.3.1(typescript@5.9.3)
+ '@valora/viem-account-hsm-gcp@1.2.17(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))':
+ dependencies:
+ '@google-cloud/kms': 5.4.0
+ '@noble/curves': 1.9.7
+ asn1js: 3.0.7
+ viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/coverage-v8@4.1.2(vitest@4.1.2)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
@@ -20817,6 +21046,12 @@ snapshots:
aes-js@3.0.0:
optional: true
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
agent-base@7.1.4: {}
ajv-draft-04@1.0.0(ajv@8.18.0):
@@ -21414,6 +21649,8 @@ snapshots:
big-integer@1.6.52: {}
+ bignumber.js@9.3.1: {}
+
birecord@0.1.1: {}
bl@4.1.0:
@@ -21505,6 +21742,8 @@ snapshots:
buffer-crc32@0.2.13: {}
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -22260,6 +22499,8 @@ snapshots:
damerau-levenshtein@1.0.8: {}
+ data-uri-to-buffer@4.0.1: {}
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -22455,8 +22696,19 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ duplexify@4.1.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ stream-shift: 1.0.3
+
eastasianwidth@0.2.0: {}
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
edit-json-file@1.8.1:
dependencies:
find-value: 1.0.13
@@ -23878,6 +24130,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fetch-blob@3.2.0:
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+
fetch-nodeshim@0.4.10: {}
fflate@0.8.2: {}
@@ -24030,6 +24287,10 @@ snapshots:
format@0.2.2: {}
+ formdata-polyfill@4.0.10:
+ dependencies:
+ fetch-blob: 3.2.0
+
forwarded-parse@2.1.2: {}
forwarded@0.2.0: {}
@@ -24110,6 +24371,23 @@ snapshots:
strip-ansi: 6.0.1
wide-align: 1.1.5
+ gaxios@7.1.3:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ rimraf: 5.0.10
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@8.1.2:
+ dependencies:
+ gaxios: 7.1.3
+ google-logging-utils: 1.1.3
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
generator-function@2.0.1: {}
gensequence@8.0.8: {}
@@ -24265,6 +24543,35 @@ snapshots:
globrex@0.1.2: {}
+ google-auth-library@10.6.1:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.1.3
+ gcp-metadata: 8.1.2
+ google-logging-utils: 1.1.3
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ google-gax@5.0.6:
+ dependencies:
+ '@grpc/grpc-js': 1.14.3
+ '@grpc/proto-loader': 0.8.0
+ duplexify: 4.1.3
+ google-auth-library: 10.6.1
+ google-logging-utils: 1.1.3
+ node-fetch: 3.3.2
+ object-hash: 3.0.0
+ proto3-json-serializer: 3.0.4
+ protobufjs: 7.5.4
+ retry-request: 8.0.2
+ rimraf: 5.0.10
+ transitivePeerDependencies:
+ - supports-color
+
+ google-logging-utils@1.1.3: {}
+
gopd@1.2.0: {}
got-fetch@5.1.10(got@12.6.1):
@@ -24624,6 +24931,14 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ http-proxy-agent@5.0.0:
+ dependencies:
+ '@tootallnate/once': 2.0.0
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -24644,6 +24959,13 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -25168,6 +25490,10 @@ snapshots:
jsesc@3.1.0: {}
+ json-bigint@1.0.0:
+ dependencies:
+ bignumber.js: 9.3.1
+
json-buffer@3.0.1: {}
json-parse-better-errors@1.0.2: {}
@@ -25223,6 +25549,17 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
katex@0.16.44:
dependencies:
commander: 8.3.0
@@ -25379,6 +25716,8 @@ snapshots:
lodash._basefor: 3.0.3
lodash.keysin: 3.0.8
+ lodash.camelcase@4.3.0: {}
+
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
@@ -25428,6 +25767,8 @@ snapshots:
split: 0.2.10
through: 2.3.8
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -26766,6 +27107,8 @@ snapshots:
node-abort-controller@3.1.1: {}
+ node-domexception@1.0.0: {}
+
node-exports-info@1.6.0:
dependencies:
array.prototype.flatmap: 1.3.3
@@ -26779,6 +27122,12 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
+ node-fetch@3.3.2:
+ dependencies:
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+
node-forge@1.4.0: {}
node-gyp-build-optional-packages@5.2.2:
@@ -26902,6 +27251,8 @@ snapshots:
object-deep-merge@2.0.0: {}
+ object-hash@3.0.0: {}
+
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -27468,6 +27819,25 @@ snapshots:
proto-list@1.2.4: {}
+ proto3-json-serializer@3.0.4:
+ dependencies:
+ protobufjs: 7.5.4
+
+ protobufjs@7.5.4:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 25.5.0
+ long: 5.3.2
+
protolint@0.56.4:
dependencies:
got: 12.6.1
@@ -28181,6 +28551,13 @@ snapshots:
retext-stringify: 4.0.0
unified: 11.0.5
+ retry-request@8.0.2:
+ dependencies:
+ extend: 3.0.2
+ teeny-request: 10.1.0
+ transitivePeerDependencies:
+ - supports-color
+
reusify@1.1.0: {}
rimraf@3.0.2:
@@ -28730,8 +29107,14 @@ snapshots:
stream-buffers@2.2.0: {}
+ stream-events@1.0.5:
+ dependencies:
+ stubs: 3.0.0
+
stream-replace-string@2.0.0: {}
+ stream-shift@1.0.3: {}
+
strict-uri-encode@2.0.0: {}
string-ts@2.3.1: {}
@@ -28836,6 +29219,8 @@ snapshots:
structured-headers@0.4.1: {}
+ stubs@3.0.0: {}
+
sturdy-websocket@0.2.1:
optional: true
@@ -29000,6 +29385,15 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
+ teeny-request@10.1.0:
+ dependencies:
+ http-proxy-agent: 5.0.0
+ https-proxy-agent: 5.0.1
+ node-fetch: 3.3.2
+ stream-events: 1.0.5
+ transitivePeerDependencies:
+ - supports-color
+
temp-dir@3.0.0: {}
tempy@3.2.0:
@@ -29622,6 +30016,8 @@ snapshots:
web-namespaces@2.0.1: {}
+ web-streams-polyfill@3.3.3: {}
+
webauthn-owner-plugin@https://codeload.github.com/exactly/webauthn-owner-plugin/tar.gz/9c0c38bd63c2aa70b60c03c815e9de108e264cda: {}
webauthn-p256@0.0.10:
diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts
index 1e1dfa3b1..e27aa8cd2 100644
--- a/server/hooks/activity.ts
+++ b/server/hooks/activity.ts
@@ -15,25 +15,21 @@ import createDebug from "debug";
import { eq, inArray } from "drizzle-orm";
import { Hono } from "hono";
import * as v from "valibot";
-import { bytesToBigInt, hexToBigInt, withRetry } from "viem";
+import { bytesToBigInt, hexToBigInt } from "viem";
import {
- auditorAbi,
exaAccountFactoryAbi,
- exaPluginAbi,
exaPreviewerAbi,
exaPreviewerAddress,
- marketAbi,
- upgradeableModularAccountAbi,
wethAddress,
} from "@exactly/common/generated/chain";
import { Address, Hash, Hex } from "@exactly/common/validation";
import database, { cards, credentials } from "../database";
+import { keeper } from "../utils/accounts";
import { createWebhook, findWebhook, headerValidator, network } from "../utils/alchemy";
import appOrigin from "../utils/appOrigin";
import decodePublicKey from "../utils/decodePublicKey";
-import keeper from "../utils/keeper";
import { sendPushNotification } from "../utils/onesignal";
import { autoCredit } from "../utils/panda";
import publicClient from "../utils/publicClient";
@@ -96,7 +92,7 @@ export default new Hono().post(
category !== "erc1155" &&
(rawContract?.rawValue && rawContract.rawValue !== "0x" ? hexToBigInt(rawContract.rawValue) > 0n : !!value),
);
- const accounts = await database.query.credentials
+ const accountLookup = await database.query.credentials
.findMany({
columns: { account: true, publicKey: true, factory: true, source: true },
where: inArray(credentials.account, [...new Set(transfers.map(({ toAddress }) => toAddress))]),
@@ -109,18 +105,16 @@ export default new Hono().post(
),
),
);
- if (Object.keys(accounts).length === 1) setUser({ id: v.parse(Address, Object.keys(accounts)[0]) });
+ if (Object.keys(accountLookup).length === 1) setUser({ id: v.parse(Address, Object.keys(accountLookup)[0]) });
const marketsByAsset = await publicClient
.readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi })
.then((p) => new Map
(p.map((m) => [v.parse(Address, m.asset), v.parse(Address, m.market)])));
const markets = new Set(marketsByAsset.values());
- const pokes = new Map<
- Address,
- { assets: Set; factory: Address; publicKey: Uint8Array; source: null | string }
- >();
+
+ const accounts = new Set();
for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) {
- if (!accounts[account]) continue;
+ if (!accountLookup[account]) continue;
if (rawContract?.address && markets.has(rawContract.address)) continue;
const asset = rawContract?.address ?? ETH;
const underlying = asset === ETH ? WETH : asset;
@@ -131,141 +125,84 @@ export default new Hono().post(
en: `${value ? `${value} ` : ""}${assetSymbol} received${marketsByAsset.has(underlying) ? " and instantly started earning yield" : ""}`,
},
}).catch((error: unknown) => captureException(error));
-
- if (pokes.has(account)) {
- pokes.get(account)?.assets.add(asset);
- } else {
- const { publicKey, factory, source } = accounts[account];
- pokes.set(account, { publicKey, factory, source, assets: new Set([asset]) });
- }
+ accounts.add(account);
}
const { "sentry-trace": sentryTrace, baggage } = getTraceData();
Promise.allSettled(
- [...pokes].map(([account, { publicKey, factory, source, assets }]) =>
- continueTrace({ sentryTrace, baggage }, () =>
- withScope((scope) =>
- startSpan(
- { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
- async (span) => {
- scope.setUser({ id: account });
- const isDeployed = !!(await publicClient.getCode({ address: account }));
- scope.setTag("exa.new", !isDeployed);
- if (!isDeployed) {
- try {
- await keeper.exaSend(
- { name: "create account", op: "exa.account", attributes: { account } },
- {
- address: factory,
- functionName: "createAccount",
- args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
- abi: exaAccountFactoryAbi,
- },
- );
- track({ event: "AccountFunded", userId: account, properties: { source } });
- } catch (error: unknown) {
- span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
- throw error;
- }
- }
- if (assets.has(ETH)) assets.delete(WETH);
- const results = await Promise.allSettled(
- [...assets]
- .filter((asset) => marketsByAsset.has(asset) || asset === ETH)
- .map(async (asset) =>
- withRetry(
- () =>
- keeper
- .exaSend(
- { name: "poke account", op: "exa.poke", attributes: { account, asset } },
- {
- address: account,
- abi: [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi],
- ...(asset === ETH
- ? { functionName: "pokeETH" }
- : {
- functionName: "poke",
- args: [marketsByAsset.get(asset)!], // eslint-disable-line @typescript-eslint/no-non-null-assertion
- }),
- },
- { ignore: ["NoBalance()"] },
- )
- .then((receipt) => {
- if (receipt) return receipt;
- throw new Error("NoBalance()");
- }),
+ [...accounts]
+ .flatMap((account) => {
+ const info = accountLookup[account];
+ return info ? [[account, info] as const] : [];
+ })
+ .map(([account, { publicKey, factory, source }]) =>
+ continueTrace({ sentryTrace, baggage }, () =>
+ withScope((scope) =>
+ startSpan(
+ { name: "account activity", op: "exa.activity", attributes: { account }, forceTransaction: true },
+ async (span) => {
+ scope.setUser({ id: account });
+ scope.setTag("exa.account", account);
+ const isDeployed = !!(await publicClient.getCode({ address: account }));
+ scope.setTag("exa.new", !isDeployed);
+ if (!isDeployed) {
+ try {
+ await keeper.exaSend(
+ { name: "create account", op: "exa.account", attributes: { account } },
{
- delay: 2000,
- retryCount: 5,
- shouldRetry: ({ error }) => {
- if (error instanceof Error && error.message === "NoBalance()") return true;
- withScope((captureScope) => {
- captureScope.setUser({ id: account });
- captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
- });
- return true;
- },
+ address: factory,
+ functionName: "createAccount",
+ args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]],
+ abi: exaAccountFactoryAbi,
},
- ),
- ),
- );
- for (const result of results) {
- if (result.status === "fulfilled") continue;
- if (result.reason instanceof Error && result.reason.message === "NoBalance()") {
- withScope((captureScope) => {
- captureScope.setUser({ id: account });
- captureScope.addEventProcessor((event) => {
- if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance";
- return event;
- });
- captureException(result.reason, {
- level: "warning",
- fingerprint: ["{{ default }}", "NoBalance"],
- });
- });
- continue;
+ );
+ track({ event: "AccountFunded", userId: account, properties: { source } });
+ } catch (error: unknown) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" });
+ throw error;
+ }
}
- span.setStatus({ code: SPAN_STATUS_ERROR, message: "poke_failed" });
- throw result.reason;
- }
- autoCredit(account)
- .then(async (auto) => {
- span.setAttribute("exa.autoCredit", auto);
- if (!auto) return;
- const credential = await database.query.credentials.findFirst({
- where: eq(credentials.account, account),
- columns: {},
- with: {
- cards: {
- columns: { id: true, mode: true },
- where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
+ await keeper
+ .poke(account, { ignore: [`NotAllowed(${account})`] })
+ .catch((error: unknown) => captureException(error, { level: "error" }));
+ autoCredit(account)
+ .then(async (auto) => {
+ span.setAttribute("exa.autoCredit", auto);
+ if (!auto) return;
+ const credential = await database.query.credentials.findFirst({
+ where: eq(credentials.account, account),
+ columns: {},
+ with: {
+ cards: {
+ columns: { id: true, mode: true },
+ where: inArray(cards.status, ["ACTIVE", "FROZEN"]),
+ },
},
- },
- });
- if (!credential || credential.cards.length === 0) return;
- const card = credential.cards[0];
- span.setAttribute("exa.card", card?.id);
- if (card?.mode !== 0) return;
- await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
- span.setAttribute("exa.mode", 1);
- sendPushNotification({
- userId: account,
- headings: { en: "Card mode changed" },
- contents: { en: "Credit mode activated" },
- }).catch((error: unknown) => captureException(error));
- })
- .catch((error: unknown) => captureException(error));
- span.setStatus({ code: SPAN_STATUS_OK });
- },
+ });
+ if (!credential || credential.cards.length === 0) return;
+ const card = credential.cards[0];
+ span.setAttribute("exa.card", card?.id);
+ if (card?.mode !== 0) return;
+ await database.update(cards).set({ mode: 1 }).where(eq(cards.id, card.id));
+ span.setAttribute("exa.mode", 1);
+ sendPushNotification({
+ userId: account,
+ headings: { en: "Card mode changed" },
+ contents: { en: "Credit mode activated" },
+ }).catch((error: unknown) => captureException(error, { level: "error" }));
+ })
+ .catch((error: unknown) => captureException(error, { level: "error" }));
+ span.setStatus({ code: SPAN_STATUS_OK });
+ },
+ ),
),
- ),
- ).catch((error: unknown) => {
- withScope((scope) => {
- scope.setUser({ id: account });
- captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
- });
- throw error;
- }),
- ),
+ ).catch((error: unknown) => {
+ withScope((captureScope) => {
+ captureScope.setUser({ id: account });
+ captureException(error, { level: "error", fingerprint: revertFingerprint(error) });
+ });
+ throw error;
+ }),
+ ),
)
.then((results) => {
getActiveSpan()?.setStatus(
@@ -274,7 +211,7 @@ export default new Hono().post(
: { code: SPAN_STATUS_ERROR, message: "activity_failed" },
);
})
- .catch((error: unknown) => captureException(error));
+ .catch((error: unknown) => captureException(error, { level: "error" }));
return c.json({});
},
);
diff --git a/server/hooks/block.ts b/server/hooks/block.ts
index 82a4b0bef..d5517d149 100644
--- a/server/hooks/block.ts
+++ b/server/hooks/block.ts
@@ -46,10 +46,10 @@ import revertReason from "@exactly/common/revertReason";
import shortenHex from "@exactly/common/shortenHex";
import { Address, Hash, Hex } from "@exactly/common/validation";
+import { keeper } from "../utils/accounts";
import { headers as alchemyHeaders, createWebhook, findWebhook, headerValidator } from "../utils/alchemy";
import appOrigin from "../utils/appOrigin";
import ensClient from "../utils/ensClient";
-import keeper from "../utils/keeper";
import { sendPushNotification } from "../utils/onesignal";
import publicClient from "../utils/publicClient";
import redis from "../utils/redis";
diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts
index 348e55731..c50f4e3b0 100644
--- a/server/hooks/panda.ts
+++ b/server/hooks/panda.ts
@@ -51,7 +51,7 @@ import { Address, type Hash, type Hex } from "@exactly/common/validation";
import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib";
import database, { cards, credentials, transactions } from "../database/index";
-import keeper from "../utils/keeper";
+import { keeper } from "../utils/accounts";
import { sendPushNotification } from "../utils/onesignal";
import { collectors, createMutex, getMutex, getUser, headerValidator, signIssuerOp, updateUser } from "../utils/panda";
import publicClient from "../utils/publicClient";
diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts
index 1252d71fa..dcb8ceef7 100644
--- a/server/hooks/persona.ts
+++ b/server/hooks/persona.ts
@@ -13,6 +13,7 @@ import {
nullable,
object,
optional,
+ parse,
picklist,
pipe,
safeParse,
@@ -21,9 +22,11 @@ import {
union,
} from "valibot";
+import { firewallAddress } from "@exactly/common/generated/chain";
import { Address } from "@exactly/common/validation";
import database, { credentials } from "../database/index";
+import { allower, keeper } from "../utils/accounts";
import { createUser } from "../utils/panda";
import { addCapita, deriveAssociateId } from "../utils/pax";
import {
@@ -39,6 +42,16 @@ import { customer } from "../utils/sardine";
import validatorHook from "../utils/validatorHook";
import type { InferOutput } from "valibot";
+
+let allowerPromise: ReturnType | undefined;
+function getAllower() {
+ allowerPromise ??= allower().catch((error: unknown) => {
+ allowerPromise = undefined;
+ throw error;
+ });
+ return allowerPromise;
+}
+
const Session = pipe(
object({
type: literal("inquiry-session"),
@@ -300,6 +313,24 @@ export default new Hono().post(
if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200);
}
+ const account = parse(Address, credential.account);
+ if (firewallAddress) {
+ try {
+ await getAllower().then((client) => client.allow(account, { ignore: [`AlreadyAllowed(${account})`] }));
+ } catch (error: unknown) {
+ captureException(error, { level: "error" });
+ return c.json({ code: "firewall error" }, 500);
+ }
+ keeper
+ .poke(account, {
+ notification: {
+ headings: { en: "Account assets updated" },
+ contents: { en: "Your funds are ready to use" },
+ },
+ })
+ .catch((error: unknown) => captureException(error, { level: "error" }));
+ }
+
// TODO implement error handling to return 200 if event should not be retried
const { id } = await createUser({
accountPurpose: fields.accountPurpose.value,
@@ -316,26 +347,17 @@ export default new Hono().post(
getActiveSpan()?.setAttributes({ "exa.pandaId": id });
setContext("persona", { inquiryId: personaShareToken, pandaId: id });
- const account = safeParse(Address, credential.account);
- if (account.success) {
- addCapita({
- birthdate: fields.birthdate.value,
- document: fields.identificationNumber.value,
- firstName: fields.nameFirst.value,
- lastName: fields.nameLast.value,
- email: fields.emailAddress.value,
- phone: fields.phoneNumber?.value ?? "",
- internalId: deriveAssociateId(account.output),
- product: "travel insurance",
- }).catch((error: unknown) => {
- captureException(error, { level: "error", extra: { pandaId: id, referenceId } });
- });
- } else {
- captureException(new Error("invalid account address"), {
- extra: { pandaId: id, referenceId, account: credential.account },
- level: "error",
- });
- }
+ addCapita({
+ birthdate: fields.birthdate.value,
+ document: fields.identificationNumber.value,
+ firstName: fields.nameFirst.value,
+ lastName: fields.nameLast.value,
+ email: fields.emailAddress.value,
+ phone: fields.phoneNumber?.value ?? "",
+ internalId: deriveAssociateId(account),
+ product: "travel insurance",
+ }).catch((error: unknown) => captureException(error, { level: "error", extra: { pandaId: id, referenceId } }));
+
addDocument(referenceId, {
id_class: { value: fields.identificationClass.value },
id_number: { value: fields.identificationNumber.value },
diff --git a/server/package.json b/server/package.json
index 79710d98a..a828985c6 100644
--- a/server/package.json
+++ b/server/package.json
@@ -32,6 +32,7 @@
"dependencies": {
"@account-kit/infra": "catalog:",
"@exactly/lib": "^0.1.0",
+ "@google-cloud/kms": "^5.3.0",
"@hono/node-server": "^1.19.11",
"@hono/sentry": "^1.2.2",
"@hono/valibot-validator": "^0.5.3",
@@ -44,6 +45,7 @@
"@simplewebauthn/server": "^13.3.0",
"@types/debug": "^4.1.13",
"@valibot/to-json-schema": "^1.6.0",
+ "@valora/viem-account-hsm-gcp": "^1.2.16",
"async-mutex": "^0.5.0",
"bullmq": "^5.71.1",
"debug": "^4.4.3",
diff --git a/server/script/openapi.ts b/server/script/openapi.ts
index 124c51240..60c0f59a6 100644
--- a/server/script/openapi.ts
+++ b/server/script/openapi.ts
@@ -29,6 +29,11 @@ process.env.REDIS_URL = "redis";
process.env.SARDINE_API_KEY = "sardine";
process.env.SARDINE_API_URL = "https://api.sardine.ai";
process.env.SEGMENT_WRITE_KEY = "segment";
+process.env.GCP_BASE64_JSON = "base64String==";
+process.env.GOOGLE_APPLICATION_CREDENTIALS = "path/to/credentials.json";
+process.env.GCP_KMS_KEY_RING = "op-sepolia";
+process.env.GCP_KMS_KEY_VERSION = "1";
+process.env.GCP_PROJECT_ID = "exa-dev";
/* eslint-disable n/no-process-exit, unicorn/no-process-exit, no-console -- cli */
import("../api")
diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts
index 7f5c24490..e1310ba42 100644
--- a/server/test/api/card.test.ts
+++ b/server/test/api/card.test.ts
@@ -1,7 +1,6 @@
+import "../mocks/accounts";
import "../mocks/auth";
import "../mocks/deployments";
-import "../mocks/keeper";
-import "../mocks/onesignal";
import "../mocks/pax";
import "../mocks/persona";
@@ -20,7 +19,7 @@ import { Address } from "@exactly/common/validation";
import app from "../../api/card";
import database, { cards, credentials } from "../../database";
-import keeper from "../../utils/keeper";
+import { keeper } from "../../utils/accounts";
import * as panda from "../../utils/panda";
import * as pax from "../../utils/pax";
import * as persona from "../../utils/persona";
diff --git a/server/test/e2e.ts b/server/test/e2e.ts
index 84b86b0a0..be8f94a99 100644
--- a/server/test/e2e.ts
+++ b/server/test/e2e.ts
@@ -1,7 +1,7 @@
///
+import "./mocks/accounts";
import "./mocks/alchemy";
import "./mocks/deployments";
-import "./mocks/keeper";
import "./mocks/onesignal";
import "./mocks/pax";
import "./mocks/sardine";
diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts
index 5358eae9d..1ec802bbd 100644
--- a/server/test/hooks/activity.test.ts
+++ b/server/test/hooks/activity.test.ts
@@ -1,6 +1,6 @@
+import "../mocks/accounts";
import "../mocks/alchemy";
import "../mocks/deployments";
-import "../mocks/keeper";
import "../mocks/onesignal";
import "../mocks/sentry";
@@ -28,8 +28,8 @@ import { exaAccountFactoryAbi, previewerAbi } from "@exactly/common/generated/ch
import database, { credentials } from "../../database";
import app from "../../hooks/activity";
+import { keeper } from "../../utils/accounts";
import * as decodePublicKey from "../../utils/decodePublicKey";
-import keeper from "../../utils/keeper";
import * as onesignal from "../../utils/onesignal";
import publicClient from "../../utils/publicClient";
import anvilClient from "../anvilClient";
@@ -55,40 +55,6 @@ describe("address activity", () => {
]);
});
- it("captures no balance once after retries", async () => {
- vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1");
- vi.spyOn(keeper, "exaSend").mockImplementation((spanOptions) =>
- Promise.resolve(
- spanOptions.op === "exa.poke" ? null : ({ status: "success" } as Awaited>),
- ),
- );
-
- const response = await appClient.index.$post({
- ...activityPayload,
- json: {
- ...activityPayload.json,
- event: {
- ...activityPayload.json.event,
- activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }],
- },
- },
- });
-
- await vi.waitUntil(
- () => vi.mocked(captureException).mock.calls.some(([error, hint]) => isNoBalance(error, hint, "warning")),
- 26_666,
- );
-
- expect(
- vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "warning")),
- ).toHaveLength(1);
- expect(
- vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "error")),
- ).toHaveLength(0);
- expect(setUser).toHaveBeenCalledWith({ id: account });
- expect(response.status).toBe(200);
- });
-
it("fails with unexpected error", async () => {
const getCode = vi.spyOn(publicClient, "getCode");
getCode.mockRejectedValue(new Error("Unexpected"));
@@ -310,6 +276,7 @@ describe("address activity", () => {
}),
}),
);
+ await anvilClient.setBalance({ address: account, value: parseEther("5") });
const response = await appClient.index.$post({
...activityPayload,
@@ -342,6 +309,7 @@ describe("address activity", () => {
cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH", message: "custom reason" }),
}),
);
+ await anvilClient.setBalance({ address: account, value: parseEther("5") });
const response = await appClient.index.$post({
...activityPayload,
@@ -374,6 +342,7 @@ describe("address activity", () => {
cause: new ContractFunctionRevertedError({ abi: [], data: "0xdeadbeef", functionName: "pokeETH" }),
}),
);
+ await anvilClient.setBalance({ address: account, value: parseEther("5") });
const response = await appClient.index.$post({
...activityPayload,
@@ -404,6 +373,7 @@ describe("address activity", () => {
vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce(
new BaseError("test", { cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH" }) }),
);
+ await anvilClient.setBalance({ address: account, value: parseEther("5") });
const response = await appClient.index.$post({
...activityPayload,
@@ -432,6 +402,7 @@ describe("address activity", () => {
it("fingerprints shouldRetry as unknown", async () => {
vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1");
vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce(new Error("unexpected"));
+ await anvilClient.setBalance({ address: account, value: parseEther("5") });
const response = await appClient.index.$post({
...activityPayload,
@@ -505,7 +476,7 @@ describe("address activity", () => {
});
it("pokes eth with value when rawValue is 0x", async () => {
- const exaSend = vi.spyOn(keeper, "exaSend");
+ const pokeSpy = vi.spyOn(keeper, "poke");
const deposit = parseEther("5");
await anvilClient.setBalance({ address: account, value: deposit });
@@ -525,22 +496,14 @@ describe("address activity", () => {
waitForWETHMarket(account, deposit),
]);
- expect(
- exaSend.mock.calls.some(
- ([spanOptions, request]) =>
- spanOptions.op === "exa.poke" &&
- request.address === account &&
- "functionName" in request &&
- request.functionName === "pokeETH",
- ),
- ).toBe(true);
+ expect(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] }));
expect(market.floatingDepositAssets).toBe(deposit);
expect(market.isCollateral).toBe(true);
expect(response.status).toBe(200);
});
it("pokes eth without value", async () => {
- const exaSend = vi.spyOn(keeper, "exaSend");
+ const pokeSpy = vi.spyOn(keeper, "poke");
const deposit = parseEther("5");
await anvilClient.setBalance({ address: account, value: deposit });
@@ -568,15 +531,7 @@ describe("address activity", () => {
waitForWETHMarket(account, deposit),
]);
- expect(
- exaSend.mock.calls.some(
- ([spanOptions, request]) =>
- spanOptions.op === "exa.poke" &&
- request.address === account &&
- "functionName" in request &&
- request.functionName === "pokeETH",
- ),
- ).toBe(true);
+ expect(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] }));
expect(market.floatingDepositAssets).toBe(deposit);
expect(market.isCollateral).toBe(true);
expect(response.status).toBe(200);
@@ -620,7 +575,7 @@ describe("address activity", () => {
});
it("pokes token without value", async () => {
- const exaSend = vi.spyOn(keeper, "exaSend");
+ const pokeSpy = vi.spyOn(keeper, "poke");
const weth = parseEther("2");
await keeper.exaSend(
{ name: "mint", op: "tx.mint" },
@@ -651,15 +606,7 @@ describe("address activity", () => {
waitForWETHMarket(account, weth),
]);
- expect(
- exaSend.mock.calls.some(
- ([spanOptions, request]) =>
- spanOptions.op === "exa.poke" &&
- request.address === account &&
- "functionName" in request &&
- request.functionName === "poke",
- ),
- ).toBe(true);
+ expect(pokeSpy).toHaveBeenCalledWith(account, expect.objectContaining({ ignore: [`NotAllowed(${account})`] }));
expect(market.floatingDepositAssets).toBe(weth);
expect(market.isCollateral).toBe(true);
expect(response.status).toBe(200);
@@ -807,6 +754,27 @@ describe("address activity", () => {
expect(setUser).toHaveBeenCalledWith({ id: account });
expect(response.status).toBe(200);
});
+
+ it("calls poke with correct ignore option", async () => {
+ const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue();
+
+ const response = await appClient.index.$post({
+ ...activityPayload,
+ json: {
+ ...activityPayload.json,
+ event: {
+ ...activityPayload.json.event,
+ activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }],
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ await vi.waitUntil(() => pokeSpy.mock.calls.some(([addr]) => addr === account), { timeout: 5000 });
+
+ expect(pokeSpy).toHaveBeenCalledWith(account, { ignore: [`NotAllowed(${account})`] });
+ });
});
async function getWETHMarket(account: Address) {
diff --git a/server/test/hooks/block.test.ts b/server/test/hooks/block.test.ts
index 2ef532e43..3ccb51416 100644
--- a/server/test/hooks/block.test.ts
+++ b/server/test/hooks/block.test.ts
@@ -47,8 +47,8 @@ import ProposalType, { decodeWithdraw } from "@exactly/common/ProposalType";
import deploy from "@exactly/plugin/deploy.json";
import app from "../../hooks/block";
+import { keeper } from "../../utils/accounts";
import ensClient from "../../utils/ensClient";
-import keeper from "../../utils/keeper";
import * as onesignal from "../../utils/onesignal";
import publicClient from "../../utils/publicClient";
import redis from "../../utils/redis";
diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts
index d3b8145bf..31176ffc9 100644
--- a/server/test/hooks/panda.test.ts
+++ b/server/test/hooks/panda.test.ts
@@ -1,5 +1,5 @@
+import "../mocks/accounts";
import "../mocks/deployments";
-import "../mocks/keeper";
import "../mocks/onesignal";
import "../mocks/panda";
import "../mocks/sardine";
@@ -46,7 +46,7 @@ import { proposalManager } from "@exactly/plugin/deploy.json";
import database, { cards, credentials, transactions } from "../../database";
import app from "../../hooks/panda";
-import keeper from "../../utils/keeper";
+import { keeper } from "../../utils/accounts";
import * as panda from "../../utils/panda";
import publicClient from "../../utils/publicClient";
import * as sardine from "../../utils/sardine";
diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts
index 87599f912..83da8c5f8 100644
--- a/server/test/hooks/persona.test.ts
+++ b/server/test/hooks/persona.test.ts
@@ -5,22 +5,43 @@ import "../mocks/sentry";
import { captureException } from "@sentry/node";
import { eq } from "drizzle-orm";
import { testClient } from "hono/testing";
-import { hexToBytes, padHex, zeroHash } from "viem";
+import { hexToBytes, padHex, parseEther, zeroHash } from "viem";
import { privateKeyToAddress } from "viem/accounts";
import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest";
import deriveAddress from "@exactly/common/deriveAddress";
+import { wethAddress } from "@exactly/common/generated/chain";
import database, { credentials } from "../../database";
import app from "../../hooks/persona";
+import { allower, keeper } from "../../utils/accounts";
import * as panda from "../../utils/panda";
import * as pax from "../../utils/pax";
import * as persona from "../../utils/persona";
+import publicClient from "../../utils/publicClient";
import * as sardine from "../../utils/sardine";
+import type * as AccountsModule from "../../utils/accounts";
+
const appClient = testClient(app);
vi.mock("@sentry/node", { spy: true });
+const mockAllow = vi.fn().mockResolvedValue({});
+
+vi.mock("../../utils/accounts", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ allower: vi.fn(() => Promise.resolve({ allow: mockAllow })),
+ };
+});
+vi.mock("@exactly/common/generated/chain", async () => {
+ const actual = await vi.importActual("@exactly/common/generated/chain");
+ return {
+ ...actual,
+ firewallAddress: "0x1234567890123456789012345678901234567890",
+ };
+});
describe("with reference", () => {
const referenceId = "hook-persona";
@@ -38,8 +59,41 @@ describe("with reference", () => {
await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, referenceId));
});
+ it("returns firewall error when allower initialization fails", async () => {
+ vi.mocked(allower).mockRejectedValueOnce(new Error("allower init failed"));
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+ const response = await appClient.index.$post({
+ ...personaPayload,
+ json: {
+ ...personaPayload.json,
+ data: {
+ ...personaPayload.json.data,
+ attributes: {
+ ...personaPayload.json.data.attributes,
+ payload: {
+ ...personaPayload.json.data.attributes.payload,
+ data: {
+ ...personaPayload.json.data.attributes.payload.data,
+ attributes: {
+ ...personaPayload.json.data.attributes.payload.data.attributes,
+ referenceId,
+ },
+ },
+ included: [...personaPayload.json.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ code: "firewall error" });
+ expect(captureException).toHaveBeenCalledWith(new Error("allower init failed"), { level: "error" });
+ });
+
it("creates a panda account", async () => {
vi.spyOn(panda, "createUser").mockResolvedValueOnce({ id: "pandaId" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_123" } });
const response = await appClient.index.$post({
@@ -384,7 +438,10 @@ describe("persona hook", () => {
});
});
- afterEach(() => vi.resetAllMocks());
+ afterEach(async () => {
+ await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "persona-ref"));
+ vi.restoreAllMocks();
+ });
it("creates panda and pax user on valid inquiry", async () => {
vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
@@ -392,7 +449,9 @@ describe("persona hook", () => {
vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
const response = await appClient.index.$post({
- header: { "persona-signature": "t=1,v1=sha256" },
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
json: {
...validPayload,
data: {
@@ -431,6 +490,271 @@ describe("persona hook", () => {
product: "travel insurance",
});
});
+
+ it("pokes assets when balances are positive", async () => {
+ const account = deriveAddress(inject("ExaAccountFactory"), {
+ x: padHex(privateKeyToAddress(padHex("0x420"))),
+ y: zeroHash,
+ });
+ const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue();
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ])
+ .mockResolvedValueOnce(parseEther("2"));
+
+ vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1"));
+
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 });
+
+ expect(pokeSpy).toHaveBeenCalledWith(account, {
+ notification: {
+ headings: { en: "Account assets updated" },
+ contents: { en: "Your funds are ready to use" },
+ },
+ });
+ });
+
+ it("pokes only eth when balance is positive", async () => {
+ const account = deriveAddress(inject("ExaAccountFactory"), {
+ x: padHex(privateKeyToAddress(padHex("0x420"))),
+ y: zeroHash,
+ });
+ const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue();
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([{ asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }])
+ .mockResolvedValueOnce(0n);
+
+ vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1"));
+
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 });
+
+ expect(pokeSpy).toHaveBeenCalledTimes(1);
+ expect(pokeSpy).toHaveBeenCalledWith(account, {
+ notification: {
+ headings: { en: "Account assets updated" },
+ contents: { en: "Your funds are ready to use" },
+ },
+ });
+ });
+
+ it("skips weth when eth balance is positive", async () => {
+ const account = deriveAddress(inject("ExaAccountFactory"), {
+ x: padHex(privateKeyToAddress(padHex("0x420"))),
+ y: zeroHash,
+ });
+ const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue();
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ])
+ .mockResolvedValueOnce(parseEther("5"))
+ .mockResolvedValueOnce(parseEther("2"));
+
+ vi.spyOn(publicClient, "getBalance").mockResolvedValue(parseEther("1"));
+
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 });
+
+ expect(pokeSpy).toHaveBeenCalledTimes(1);
+ expect(pokeSpy).toHaveBeenCalledWith(account, {
+ notification: {
+ headings: { en: "Account assets updated" },
+ contents: { en: "Your funds are ready to use" },
+ },
+ });
+ });
+
+ it("does not poke when balances are zero", async () => {
+ const exaSendSpy = vi.spyOn(keeper, "exaSend").mockResolvedValue(undefined as never);
+
+ const readContractSpy = vi.spyOn(publicClient, "readContract");
+ readContractSpy
+ .mockResolvedValueOnce([
+ { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" },
+ ])
+ .mockResolvedValueOnce(0n);
+
+ vi.spyOn(publicClient, "getBalance").mockResolvedValue(0n);
+
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ await vi.waitFor(
+ () => {
+ expect(exaSendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ op: "exa.poke" }), expect.anything());
+ },
+ { timeout: 100, interval: 20 },
+ );
+ });
+
+ it("captures exception when keeper.poke fails", async () => {
+ const pokeSpy = vi.spyOn(keeper, "poke").mockRejectedValueOnce(new Error("poke failed"));
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+ await vi.waitFor(() => {
+ expect(captureException).toHaveBeenCalledWith(
+ new Error("poke failed"),
+ expect.objectContaining({ level: "error" }),
+ );
+ });
+ pokeSpy.mockRestore();
+ });
+
+ it("returns error when firewall call fails", async () => {
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "new-panda-id" });
+ vi.spyOn(pax, "addCapita").mockResolvedValue({});
+ vi.spyOn(sardine, "customer").mockResolvedValueOnce({ sessionKey: "test", status: "Success", level: "low" });
+
+ mockAllow.mockRejectedValueOnce(new Error("Firewall error"));
+
+ const response = await appClient.index.$post({
+ header: {
+ "persona-signature": "t=1733865120,v1=debbacfe1b0c5f8797a1d68e8428fba435aa4ca3b5d9a328c3c96ee4d04d84df",
+ },
+ json: {
+ ...validPayload,
+ data: {
+ ...validPayload.data,
+ attributes: {
+ ...validPayload.data.attributes,
+ payload: {
+ ...validPayload.data.attributes.payload,
+ included: [...validPayload.data.attributes.payload.included],
+ },
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ code: "firewall error" });
+ });
});
describe("manteca template", () => {
@@ -450,6 +774,7 @@ describe("manteca template", () => {
it("handles manteca template and adds document", async () => {
vi.spyOn(persona, "addDocument").mockResolvedValueOnce({ data: { id: "doc_manteca" } });
+ vi.spyOn(panda, "createUser").mockResolvedValue({ id: "should-not-be-called" });
const response = await appClient.index.$post({
header: { "persona-signature": "t=1,v1=sha256" },
diff --git a/server/test/mocks/keeper.ts b/server/test/mocks/accounts.ts
similarity index 79%
rename from server/test/mocks/keeper.ts
rename to server/test/mocks/accounts.ts
index 428dd177e..639f76e6a 100644
--- a/server/test/mocks/keeper.ts
+++ b/server/test/mocks/accounts.ts
@@ -1,3 +1,5 @@
+import "./deployments";
+
import path from "node:path";
import { createWalletClient, http, keccak256, toBytes, type NonceManagerSource } from "viem";
import { privateKeyToAccount } from "viem/accounts";
@@ -7,23 +9,24 @@ import { expect, vi } from "vitest";
import alchemyAPIKey from "@exactly/common/alchemyAPIKey";
import chain from "@exactly/common/generated/chain";
-import type * as keeper from "../../utils/keeper";
+import type * as accounts from "../../utils/accounts";
import type * as nonceManager from "../../utils/nonceManager";
export let keeperClient: ReturnType<
typeof createWalletClient, typeof chain, ReturnType>
>;
-vi.mock("../../utils/keeper", async (importOriginal) => {
- const original = await importOriginal();
+vi.mock("../../utils/accounts", async (importOriginal) => {
+ const original = await importOriginal();
return {
...original,
- default: createWalletClient({
+ allower: vi.fn(() => Promise.resolve({ allow: vi.fn().mockResolvedValue({}) })),
+ keeper: createWalletClient({
chain,
transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`),
account: privateKeyToAccount(
keccak256(toBytes(path.relative(path.resolve(__dirname, ".."), expect.getState().testPath ?? ""))), // eslint-disable-line unicorn/prefer-module
- { nonceManager: original.default.account.nonceManager },
+ { nonceManager: original.keeper.account.nonceManager },
),
}).extend((closureClient) => {
keeperClient = closureClient;
diff --git a/server/test/utils/keeper.test.ts b/server/test/utils/accounts.test.ts
similarity index 98%
rename from server/test/utils/keeper.test.ts
rename to server/test/utils/accounts.test.ts
index a10c147bd..8225394ff 100644
--- a/server/test/utils/keeper.test.ts
+++ b/server/test/utils/accounts.test.ts
@@ -1,5 +1,5 @@
+import { keeperClient, nonceSource } from "../mocks/accounts";
import "../mocks/deployments";
-import { keeperClient, nonceSource } from "../mocks/keeper";
import "../mocks/sentry";
import { captureException, withScope } from "@sentry/node";
@@ -9,13 +9,13 @@ import { afterEach, describe, expect, inject, it, vi } from "vitest";
import { auditorAbi } from "@exactly/common/generated/chain";
-import keeper from "../../utils/keeper";
+import { keeper } from "../../utils/accounts";
import nonceManager from "../../utils/nonceManager";
import publicClient from "../../utils/publicClient";
+import type { Hex } from "@exactly/common/validation";
import type * as sentry from "@sentry/node";
import type * as timers from "node:timers/promises";
-import type { Hex } from "viem";
describe("fault tolerance", () => {
it("recovers if transaction is missing", async () => {
diff --git a/server/test/utils/gcp.test.ts b/server/test/utils/gcp.test.ts
new file mode 100644
index 000000000..15c1a2035
--- /dev/null
+++ b/server/test/utils/gcp.test.ts
@@ -0,0 +1,88 @@
+import { access, writeFile } from "node:fs/promises";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { initializeGcpCredentials, isRetryableKmsError, resetGcpInitialization } from "../../utils/gcp";
+
+vi.mock("node:fs/promises", () => ({
+ writeFile: vi.fn(),
+ access: vi.fn(),
+}));
+
+const mockWriteFile = vi.mocked(writeFile);
+const mockAccess = vi.mocked(access);
+
+describe("gcp credentials security", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetGcpInitialization();
+ mockAccess.mockRejectedValue(new Error("File not found"));
+ });
+
+ it("creates credentials file with secure permissions (0o600)", async () => {
+ await initializeGcpCredentials();
+
+ expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), {
+ mode: 0o600,
+ });
+ });
+
+ it("returns early when credentials already exist", async () => {
+ mockAccess.mockResolvedValue();
+
+ await initializeGcpCredentials();
+
+ expect(mockWriteFile).not.toHaveBeenCalled();
+ });
+});
+
+describe("isRetryableKmsError", () => {
+ it("returns false for non-Error values", () => {
+ expect(isRetryableKmsError("string")).toBe(false);
+ expect(isRetryableKmsError(null)).toBe(false);
+ expect(isRetryableKmsError(42)).toBe(false);
+ });
+
+ it("returns true for numeric gRPC codes (14, 4, 13, 8)", () => {
+ for (const code of [14, 4, 13, 8]) {
+ const error = Object.assign(new Error("grpc error"), { code });
+ expect(isRetryableKmsError(error)).toBe(true);
+ }
+ });
+
+ it("returns false for non-retryable numeric codes", () => {
+ const error = Object.assign(new Error("grpc error"), { code: 3 });
+ expect(isRetryableKmsError(error)).toBe(false);
+ });
+
+ it("returns true for string gRPC codes", () => {
+ for (const code of ["UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL", "RESOURCE_EXHAUSTED"]) {
+ const error = Object.assign(new Error("grpc error"), { code });
+ expect(isRetryableKmsError(error)).toBe(true);
+ }
+ });
+
+ it("returns false for non-retryable string codes", () => {
+ const error = Object.assign(new Error("grpc error"), { code: "PERMISSION_DENIED" });
+ expect(isRetryableKmsError(error)).toBe(false);
+ });
+
+ it("returns true for retryable message substrings", () => {
+ for (const message of ["network error", "request timeout", "service unavailable", "internal error occurred"]) {
+ expect(isRetryableKmsError(new Error(message))).toBe(true);
+ }
+ });
+
+ it("returns true for retryable error names", () => {
+ const networkError = new Error("fail");
+ networkError.name = "NetworkError";
+ expect(isRetryableKmsError(networkError)).toBe(true);
+
+ const timeoutError = new Error("fail");
+ timeoutError.name = "TimeoutError";
+ expect(isRetryableKmsError(timeoutError)).toBe(true);
+ });
+
+ it("returns false for generic errors without retryable signals", () => {
+ expect(isRetryableKmsError(new Error("something went wrong"))).toBe(false);
+ });
+});
diff --git a/server/utils/accounts.ts b/server/utils/accounts.ts
new file mode 100644
index 000000000..43bc5d54e
--- /dev/null
+++ b/server/utils/accounts.ts
@@ -0,0 +1,416 @@
+import { KeyManagementServiceClient } from "@google-cloud/kms";
+import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core";
+import { captureException, captureMessage, startSpan, withScope } from "@sentry/node";
+import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp";
+import { setTimeout } from "node:timers/promises";
+import { parse, safeParse } from "valibot";
+import {
+ createWalletClient,
+ encodeFunctionData,
+ erc20Abi,
+ getContractError,
+ http,
+ InvalidInputRpcError,
+ keccak256,
+ RawContractError,
+ WaitForTransactionReceiptTimeoutError,
+ withRetry,
+ type HttpTransport,
+ type LocalAccount,
+ type MaybePromise,
+ type Prettify,
+ type PrivateKeyAccount,
+ type TransactionReceipt,
+ type WalletClient,
+ type WriteContractParameters,
+} from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+
+import alchemyAPIKey from "@exactly/common/alchemyAPIKey";
+import chain, {
+ auditorAbi,
+ exaPluginAbi,
+ exaPreviewerAbi,
+ exaPreviewerAddress,
+ firewallAbi,
+ firewallAddress,
+ marketAbi,
+ upgradeableModularAccountAbi,
+ wethAddress,
+} from "@exactly/common/generated/chain";
+import revertReason from "@exactly/common/revertReason";
+import { Address, Hash } from "@exactly/common/validation";
+
+import { GOOGLE_APPLICATION_CREDENTIALS, hasCredentials, initializeGcpCredentials, isRetryableKmsError } from "./gcp";
+import nonceManager from "./nonceManager";
+import { sendPushNotification } from "./onesignal";
+import publicClient, { captureRequests, Requests } from "./publicClient";
+import revertFingerprint from "./revertFingerprint";
+import traceClient from "./traceClient";
+
+if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url");
+
+if (!process.env.GCP_PROJECT_ID) throw new Error("GCP_PROJECT_ID is required when using GCP KMS");
+const projectId = process.env.GCP_PROJECT_ID;
+if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) {
+ throw new Error("GCP_PROJECT_ID must be a valid GCP project ID format");
+}
+
+if (!process.env.GCP_KMS_KEY_RING) throw new Error("GCP_KMS_KEY_RING is required when using GCP KMS");
+const keyRing = process.env.GCP_KMS_KEY_RING;
+if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("GCP_KMS_KEY_VERSION is required when using GCP KMS");
+const version = process.env.GCP_KMS_KEY_VERSION;
+if (!/^\d+$/.test(version)) throw new Error("GCP_KMS_KEY_VERSION must be a numeric version number");
+
+export const keeper = createWalletClient({
+ chain,
+ transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, {
+ batch: true,
+ async onFetchRequest(request) {
+ const clone = request.clone();
+ captureRequests(parse(Requests, await clone.json()));
+ },
+ }),
+ account: privateKeyToAccount(
+ parse(Hash, process.env.KEEPER_PRIVATE_KEY, {
+ message: "invalid keeper private key",
+ }),
+ { nonceManager },
+ ),
+}).extend(extender);
+
+const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
+const WETH = parse(Address, wethAddress);
+
+export function extender(client: WalletClient) {
+ const send = withExaSend(client);
+
+ return {
+ ...send,
+ poke: async (
+ accountAddress: Address,
+ options?: { ignore?: string[]; notification?: { contents: { en: string }; headings: { en: string } } },
+ ) => {
+ const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi];
+ const marketsByAsset = await publicClient
+ .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi })
+ .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)])));
+
+ const assetsToPoke: { asset: Address; market: Address | null }[] = [];
+
+ const balances = await Promise.allSettled([
+ publicClient
+ .getBalance({ address: accountAddress })
+ .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({
+ asset: ETH,
+ market: null,
+ balance,
+ })),
+ ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({
+ asset,
+ market,
+ balance: await publicClient.readContract({
+ address: asset,
+ functionName: "balanceOf",
+ args: [accountAddress],
+ abi: erc20Abi,
+ }),
+ })),
+ ]).then((s) => {
+ return s.flatMap((result) => {
+ if (result.status === "rejected") {
+ captureException(result.reason, { level: "error" });
+ return [];
+ }
+ return [result.value];
+ });
+ });
+
+ const hasETH = balances.some((r) => r.asset === ETH && r.balance > 0n);
+ for (const { asset, market, balance } of balances) {
+ if (hasETH && asset === WETH) continue;
+ if (balance > 0n) assetsToPoke.push({ asset, market });
+ }
+
+ const pokes = await Promise.allSettled(
+ assetsToPoke.map(({ asset, market }) =>
+ withRetry(
+ () =>
+ send.exaSend(
+ {
+ name: "poke account",
+ op: "exa.poke",
+ attributes: { account: accountAddress, asset },
+ },
+ asset === ETH
+ ? {
+ address: accountAddress,
+ abi: combinedAccountAbi,
+ functionName: "pokeETH",
+ }
+ : {
+ address: accountAddress,
+ abi: combinedAccountAbi,
+ functionName: "poke",
+ args: [market],
+ },
+ ...(options?.ignore ? [{ ignore: options.ignore }] : []),
+ ),
+ {
+ retryCount: 10,
+ delay: ({ count }) => Math.trunc(1 << count) * 60,
+ },
+ ),
+ ),
+ ).then((r) => {
+ return r.flatMap((result) => {
+ if (result.status === "rejected") {
+ captureException(result.reason, { level: "error" });
+ return [];
+ }
+
+ return result.value ?? [];
+ });
+ });
+
+ if (options?.notification && pokes.length > 0) {
+ sendPushNotification({
+ userId: accountAddress,
+ headings: options.notification.headings,
+ contents: options.notification.contents,
+ }).catch((error: unknown) => captureException(error, { level: "error" }));
+ }
+ },
+ };
+}
+
+export function withExaSend(
+ client: WalletClient & {
+ account: LocalAccount;
+ },
+) {
+ return {
+ exaSend: async (
+ spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>,
+ call: Prettify>,
+ options?: {
+ ignore?: ((reason: string) => MaybePromise) | string[];
+ level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false;
+ onHash?: (hash: Hash) => MaybePromise;
+ onReceipt?: (receipt: TransactionReceipt) => MaybePromise;
+ },
+ ) =>
+ withScope((scope) =>
+ startSpan({ forceTransaction: true, ...spanOptions }, async (span) => {
+ const account = safeParse(Address, spanOptions.attributes?.account);
+ if (account.success) scope.setUser({ id: account.output });
+ try {
+ scope.setContext("tx", { call });
+ span.setAttributes({
+ "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`,
+ "tx.from": client.account.address,
+ "tx.to": call.address,
+ });
+ const txOptions = {
+ type: "eip1559",
+ maxFeePerGas: 1_000_000_000n,
+ maxPriorityFeePerGas: 1_000_000n,
+ gas: 5_000_000n,
+ } as const;
+ const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () =>
+ publicClient.simulateContract({ account: client.account, ...txOptions, ...call }),
+ );
+ const {
+ abi: _,
+ account: __,
+ address: ___,
+ ...request
+ } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest };
+ scope.setContext("tx", { request });
+ const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () =>
+ client.prepareTransactionRequest({
+ to: call.address,
+ data: encodeFunctionData(call),
+ ...txOptions,
+ nonceManager,
+ }),
+ );
+ scope.setContext("tx", { request, prepared });
+ span.setAttribute("tx.nonce", prepared.nonce);
+ const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () =>
+ client.signTransaction(prepared),
+ );
+ const hash = keccak256(serializedTransaction);
+ scope.setContext("tx", { request, prepared, hash });
+ span.setAttribute("tx.hash", hash);
+ const abortController = new AbortController();
+ const [, receiptResult] = await Promise.allSettled([
+ (async () => {
+ while (!abortController.signal.aborted) {
+ await Promise.allSettled([
+ startSpan({ name: "send transaction", op: "tx.send" }, () =>
+ publicClient.sendRawTransaction({ serializedTransaction }),
+ ).catch((error: unknown) => {
+ captureException(error, { level: "error" });
+ throw error;
+ }),
+ setTimeout(10_000, null, { signal: abortController.signal }),
+ ]);
+ }
+ })(),
+ startSpan({ name: "wait for receipt", op: "tx.wait" }, () =>
+ publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }),
+ )
+ .catch((error: unknown) => {
+ if (error instanceof WaitForTransactionReceiptTimeoutError) {
+ startSpan(
+ { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } },
+ (resetSpan) => {
+ const info = nonceManager.info({ address: client.account.address, chainId: chain.id });
+ resetSpan.setAttribute("exa.reset", true);
+ resetSpan.setAttribute("exa.delta", info.delta);
+ resetSpan.setAttribute("exa.nonce", info.nonce);
+ nonceManager.hardReset({ address: client.account.address, chainId: chain.id });
+ },
+ );
+ }
+ throw error;
+ })
+ .finally(() => {
+ abortController.abort();
+ }),
+ Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) =>
+ captureException(error, { level: "error" }),
+ ),
+ ]);
+ if (receiptResult.status === "rejected") throw receiptResult.reason;
+ const receipt = receiptResult.value;
+ scope.setContext("tx", { request, receipt });
+ const [trace] = await Promise.all([
+ startSpan({ name: "trace transaction", op: "tx.trace" }, () =>
+ withRetry(() => traceClient.traceTransaction(hash), {
+ delay: 1000,
+ retryCount: 10,
+ shouldRetry: ({ error }) => error instanceof InvalidInputRpcError,
+ }).catch((error: unknown) => {
+ captureException(error, { level: "error" });
+ return null;
+ }),
+ ),
+ Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) =>
+ captureException(error, { level: "error" }),
+ ),
+ ]);
+ scope.setContext("tx", { request, receipt, trace });
+ if (receipt.status !== "success") {
+ if (!trace) throw new Error("no trace");
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error
+ throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] });
+ }
+ span.setStatus({ code: SPAN_STATUS_OK });
+ return receipt;
+ } catch (error: unknown) {
+ const reason = revertReason(error, { fallback: "message", withArguments: true });
+ if (options?.ignore) {
+ const ignore =
+ typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason);
+ if (ignore) {
+ span.setAttribute("exa.error", reason);
+ span.setStatus({ code: SPAN_STATUS_OK });
+ return ignore === true ? null : ignore;
+ }
+ }
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: reason });
+ const level =
+ typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error");
+ if (level) {
+ withScope((captureScope) => {
+ const fingerprint = revertFingerprint(error);
+ if (fingerprint[1] && fingerprint[1] !== "unknown") {
+ const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1];
+ captureScope.addEventProcessor((event) => {
+ if (event.exception?.values?.[0]) event.exception.values[0].type = type;
+ return event;
+ });
+ }
+ captureException(error, { level, fingerprint });
+ });
+ }
+ throw error;
+ }
+ }),
+ ),
+ };
+}
+
+export async function getAccount(): Promise {
+ await initializeGcpCredentials();
+
+ if (!(await hasCredentials())) {
+ throw new Error(
+ `gcp credentials file not found at ${GOOGLE_APPLICATION_CREDENTIALS}. ` +
+ `ensure GCP_BASE64_JSON environment variable is set.`,
+ );
+ }
+
+ try {
+ const account = await withRetry(
+ () =>
+ gcpHsmToAccount({
+ hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${keyRing}/cryptoKeys/allower/cryptoKeyVersions/${version}`,
+ kmsClient: new KeyManagementServiceClient({
+ keyFilename: GOOGLE_APPLICATION_CREDENTIALS,
+ }),
+ }),
+ {
+ delay: 2000,
+ retryCount: 3,
+ shouldRetry: ({ error }) => isRetryableKmsError(error),
+ },
+ );
+
+ account.nonceManager = nonceManager;
+ return account;
+ } catch (error: unknown) {
+ captureException(error, { level: "error" });
+ throw error;
+ }
+}
+
+export async function allower() {
+ return createWalletClient({
+ chain,
+ transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, {
+ batch: true,
+ async onFetchRequest(request) {
+ try {
+ captureRequests(parse(Requests, await request.clone().json()));
+ } catch (error: unknown) {
+ captureMessage("failed to parse or capture rpc requests", {
+ level: "error",
+ extra: { error },
+ });
+ }
+ },
+ }),
+ account: await getAccount(),
+ }).extend((client: WalletClient) => {
+ const send = withExaSend(client);
+ return {
+ ...send,
+ allow: async (account: Address, options?: { ignore?: string[] }) => {
+ if (!firewallAddress) throw new Error("firewall address not configured");
+ return send.exaSend(
+ { forceTransaction: true, name: "firewall.allow", op: "exa.firewall", attributes: { account } },
+ {
+ address: firewallAddress,
+ functionName: "allow",
+ args: [account, true],
+ abi: firewallAbi,
+ },
+ options?.ignore ? { ignore: options.ignore } : undefined,
+ );
+ },
+ };
+ });
+}
diff --git a/server/utils/gcp.ts b/server/utils/gcp.ts
new file mode 100644
index 000000000..cadfbde95
--- /dev/null
+++ b/server/utils/gcp.ts
@@ -0,0 +1,72 @@
+import { access, writeFile } from "node:fs/promises";
+import { number, object, safeParse, string } from "valibot";
+
+const DECODING_ITERATIONS = 3;
+export const GOOGLE_APPLICATION_CREDENTIALS = "/tmp/gcp-service-account.json";
+
+if (!process.env.GCP_BASE64_JSON) throw new Error("GCP_BASE64_JSON is required when using GCP KMS");
+const gcpBase64Json = process.env.GCP_BASE64_JSON;
+
+let initializationPromise: null | Promise = null;
+
+export function resetGcpInitialization() {
+ initializationPromise = null;
+}
+
+export async function initializeGcpCredentials() {
+ if (initializationPromise) {
+ return initializationPromise;
+ }
+
+ initializationPromise = (async () => {
+ if (await hasCredentials()) {
+ return;
+ }
+
+ let json = gcpBase64Json;
+ for (let index = 0; index < DECODING_ITERATIONS; index++) {
+ json = Buffer.from(json, "base64").toString("utf8");
+ }
+ await writeFile(GOOGLE_APPLICATION_CREDENTIALS, json, { mode: 0o600 });
+ })().catch((error: unknown) => {
+ initializationPromise = null;
+ throw error;
+ });
+
+ return initializationPromise;
+}
+
+export async function hasCredentials(): Promise {
+ return access(GOOGLE_APPLICATION_CREDENTIALS)
+ .then(() => true)
+ .catch(() => false);
+}
+
+export function isRetryableKmsError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+
+ const numericResult = safeParse(object({ code: number() }), error);
+ if (numericResult.success) {
+ const code = numericResult.output.code;
+ return code === 14 || code === 4 || code === 13 || code === 8;
+ }
+
+ const stringResult = safeParse(object({ code: string() }), error);
+ if (stringResult.success) {
+ const code = stringResult.output.code;
+ return (
+ code === "UNAVAILABLE" || code === "DEADLINE_EXCEEDED" || code === "INTERNAL" || code === "RESOURCE_EXHAUSTED"
+ );
+ }
+
+ const message = error.message.toLowerCase();
+ return (
+ message.includes("network") ||
+ message.includes("timeout") ||
+ message.includes("unavailable") ||
+ message.includes("internal error") ||
+ message.includes("service unavailable") ||
+ error.name === "NetworkError" ||
+ error.name === "TimeoutError"
+ );
+}
diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts
deleted file mode 100644
index 0b767c0d7..000000000
--- a/server/utils/keeper.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core";
-import { captureException, startSpan, withScope } from "@sentry/node";
-import { setTimeout } from "node:timers/promises";
-import { parse, safeParse } from "valibot";
-import {
- createWalletClient,
- encodeFunctionData,
- getContractError,
- http,
- InvalidInputRpcError,
- keccak256,
- RawContractError,
- WaitForTransactionReceiptTimeoutError,
- withRetry,
- type HttpTransport,
- type MaybePromise,
- type Prettify,
- type PrivateKeyAccount,
- type TransactionReceipt,
- type WalletClient,
- type WriteContractParameters,
-} from "viem";
-import { privateKeyToAccount } from "viem/accounts";
-
-import alchemyAPIKey from "@exactly/common/alchemyAPIKey";
-import chain from "@exactly/common/generated/chain";
-import revertReason from "@exactly/common/revertReason";
-import { Address, Hash } from "@exactly/common/validation";
-
-import nonceManager from "./nonceManager";
-import publicClient, { captureRequests, Requests } from "./publicClient";
-import revertFingerprint from "./revertFingerprint";
-import traceClient from "./traceClient";
-
-if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url");
-
-export default createWalletClient({
- chain,
- transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, {
- batch: true,
- async onFetchRequest(request) {
- captureRequests(parse(Requests, await request.json()));
- },
- }),
- account: privateKeyToAccount(
- parse(Hash, process.env.KEEPER_PRIVATE_KEY, {
- message: "invalid keeper private key",
- }),
- { nonceManager },
- ),
-}).extend(extender);
-
-export function extender(keeper: WalletClient) {
- return {
- exaSend: async (
- spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>,
- call: Prettify>,
- options?: {
- ignore?: ((reason: string) => MaybePromise) | string[];
- level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false;
- onHash?: (hash: Hash) => MaybePromise;
- },
- ) =>
- withScope((scope) =>
- startSpan({ forceTransaction: true, ...spanOptions }, async (span) => {
- const account = safeParse(Address, spanOptions.attributes?.account);
- if (account.success) scope.setUser({ id: account.output });
- try {
- scope.setContext("tx", { call });
- span.setAttributes({
- "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`,
- "tx.from": keeper.account.address,
- "tx.to": call.address,
- });
- const txOptions = {
- type: "eip1559",
- maxFeePerGas: 1_000_000_000n,
- maxPriorityFeePerGas: 1_000_000n,
- gas: 5_000_000n,
- } as const;
- const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () =>
- publicClient.simulateContract({ account: keeper.account, ...txOptions, ...call }),
- );
- const {
- abi: _,
- account: __,
- address: ___,
- ...request
- } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest };
- scope.setContext("tx", { request });
- const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () =>
- keeper.prepareTransactionRequest({
- to: call.address,
- data: encodeFunctionData(call),
- ...txOptions,
- nonceManager,
- }),
- );
- scope.setContext("tx", { request, prepared });
- span.setAttribute("tx.nonce", prepared.nonce);
- const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () =>
- keeper.signTransaction(prepared),
- );
- const hash = keccak256(serializedTransaction);
- scope.setContext("tx", { request, prepared, hash });
- span.setAttribute("tx.hash", hash);
- const abortController = new AbortController();
- const [, receiptResult] = await Promise.allSettled([
- (async () => {
- while (!abortController.signal.aborted) {
- await Promise.allSettled([
- startSpan({ name: "send transaction", op: "tx.send" }, () =>
- publicClient.sendRawTransaction({ serializedTransaction }),
- ).catch((error: unknown) => {
- captureException(error, { level: "error" });
- throw error;
- }),
- setTimeout(10_000, null, { signal: abortController.signal }),
- ]);
- }
- })(),
- startSpan({ name: "wait for receipt", op: "tx.wait" }, () =>
- publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }),
- )
- .catch((error: unknown) => {
- if (error instanceof WaitForTransactionReceiptTimeoutError) {
- startSpan(
- { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } },
- (resetSpan) => {
- const info = nonceManager.info({ address: keeper.account.address, chainId: chain.id });
- resetSpan.setAttribute("exa.reset", true);
- resetSpan.setAttribute("exa.delta", info.delta);
- resetSpan.setAttribute("exa.nonce", info.nonce);
- nonceManager.hardReset({ address: keeper.account.address, chainId: chain.id });
- },
- );
- }
- throw error;
- })
- .finally(() => {
- abortController.abort();
- }),
- Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) =>
- captureException(error, { level: "error" }),
- ),
- ]);
- if (receiptResult.status === "rejected") throw receiptResult.reason;
- const receipt = receiptResult.value;
- scope.setContext("tx", { request, receipt });
- const trace = await startSpan({ name: "trace transaction", op: "tx.trace" }, () =>
- withRetry(() => traceClient.traceTransaction(hash), {
- delay: 1000,
- retryCount: 10,
- shouldRetry: ({ error }) => error instanceof InvalidInputRpcError,
- }).catch((error: unknown) => {
- captureException(error, { level: "error" });
- return null;
- }),
- );
- scope.setContext("tx", { request, receipt, trace });
- if (receipt.status !== "success") {
- if (!trace) throw new Error("no trace");
- // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error
- throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] });
- }
- span.setStatus({ code: SPAN_STATUS_OK });
- return receipt;
- } catch (error: unknown) {
- const reason = revertReason(error, { fallback: "message", withArguments: true });
- if (options?.ignore) {
- const ignore =
- typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason);
- if (ignore) {
- span.setAttribute("exa.error", reason);
- span.setStatus({ code: SPAN_STATUS_OK });
- return ignore === true ? null : ignore;
- }
- }
- span.setStatus({ code: SPAN_STATUS_ERROR, message: reason });
- const level =
- typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error");
- if (level) {
- withScope((captureScope) => {
- const fingerprint = revertFingerprint(error);
- if (fingerprint[1] && fingerprint[1] !== "unknown") {
- const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1];
- captureScope.addEventProcessor((event) => {
- if (event.exception?.values?.[0]) event.exception.values[0].type = type;
- return event;
- });
- }
- captureException(error, { level, fingerprint });
- });
- }
- throw error;
- }
- }),
- ),
- };
-}
diff --git a/server/vitest.config.mts b/server/vitest.config.mts
index 4d9ff859d..3d4b63e2b 100644
--- a/server/vitest.config.mts
+++ b/server/vitest.config.mts
@@ -19,6 +19,10 @@ export default defineConfig({
BRIDGE_API_KEY: "bridge",
BRIDGE_API_URL: "https://bridge.test",
EXPO_PUBLIC_ALCHEMY_API_KEY: " ",
+ GCP_BASE64_JSON: "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K",
+ GCP_KMS_KEY_RING: "op-sepolia",
+ GCP_KMS_KEY_VERSION: "1",
+ GCP_PROJECT_ID: "exa-dev",
INTERCOM_IDENTITY_KEY: "a9cBeTfEtGPSQ58REZP35Bx00ofajvStEc8TTuBtSmk",
ISSUER_PRIVATE_KEY: padHex("0x420"),
MANTECA_API_URL: "https://manteca.test",