From 50e6af9d258cd54d08cecd8d43ea7dca44496331 Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:53:55 +0800 Subject: [PATCH 1/7] feat(wallets): add TurnkeyWalletProvider (TS remote enclave signer) Pure signer over Turnkey's hosted API (keys in AWS Nitro enclaves): three sign.* overrides + CALLS_ARBITRARY/PAYMASTER_SPONSOR, lazy optional-peer loading (@turnkey/sdk-server + @turnkey/viem), strictDefault SigningPolicy enforced before every billable call, explicit EIP712Domain injection (0.14.x stripping trap), expectedChainId fail-closed pin. --- typescript/package.json | 17 +- typescript/pnpm-lock.yaml | 1359 ++++++++++++++++++- typescript/src/index.ts | 3 + typescript/src/wallets/index.ts | 1 + typescript/src/wallets/turnkey/index.ts | 22 + typescript/src/wallets/turnkey/provider.ts | 408 ++++++ typescript/src/wallets/turnkey/sdkLoader.ts | 94 ++ typescript/src/wallets/turnkey/types.ts | 70 + typescript/tests/turnkeyProvider.test.ts | 653 +++++++++ 9 files changed, 2623 insertions(+), 4 deletions(-) create mode 100644 typescript/src/wallets/turnkey/index.ts create mode 100644 typescript/src/wallets/turnkey/provider.ts create mode 100644 typescript/src/wallets/turnkey/sdkLoader.ts create mode 100644 typescript/src/wallets/turnkey/types.ts create mode 100644 typescript/tests/turnkeyProvider.test.ts diff --git a/typescript/package.json b/typescript/package.json index 66ef3ee..d275c44 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -83,17 +83,20 @@ "example:a2a-server": "tsx examples/a2a-agent/src/server.ts", "example:a2a-buyer": "tsx examples/a2a-agent/scripts/buyer.ts", "smoke:testnet": "tsx examples/smoke/readonly.ts", - "e2e:altana": "tsx examples/altana/e2e.ts" + "e2e:altana": "tsx examples/altana/e2e.ts", + "e2e:turnkey": "tsx examples/turnkey/e2e.ts" }, "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.4.0", "dotenv": "^16.4.0", - "viem": "^2.21.0" + "viem": "^2.24.2" }, "devDependencies": { "@altananetwork/sdk": "0.5.1", "@biomejs/biome": "^1.8.0", + "@turnkey/sdk-server": "8.1.0", + "@turnkey/viem": "0.14.34", "@types/node": "^20.0.0", "tsup": "^8.0.0", "tsx": "^4.23.0", @@ -101,11 +104,19 @@ "vitest": "^2.0.0" }, "peerDependencies": { - "@altananetwork/sdk": ">=0.3.3 <0.6.0" + "@altananetwork/sdk": ">=0.3.3 <0.6.0", + "@turnkey/sdk-server": ">=7.0.0 <9.0.0", + "@turnkey/viem": ">=0.14.32 <0.15.0" }, "peerDependenciesMeta": { "@altananetwork/sdk": { "optional": true + }, + "@turnkey/sdk-server": { + "optional": true + }, + "@turnkey/viem": { + "optional": true } }, "pnpm": { diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 11cffbd..a111ae2 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: specifier: ^16.4.0 version: 16.6.1 viem: - specifier: ^2.21.0 + specifier: ^2.24.2 version: 2.54.6(typescript@5.9.3)(zod@4.4.3) devDependencies: '@altananetwork/sdk': @@ -27,6 +27,12 @@ importers: '@biomejs/biome': specifier: ^1.8.0 version: 1.9.4 + '@turnkey/sdk-server': + specifier: 8.1.0 + version: 8.1.0(typescript@5.9.3)(zod@4.4.3) + '@turnkey/viem': + specifier: 0.14.34 + version: 0.14.34(typescript@5.9.3)(viem@2.54.6(typescript@5.9.3)(zod@4.4.3))(zod@4.4.3) '@types/node': specifier: ^20.0.0 version: 20.19.43 @@ -554,6 +560,26 @@ packages: cpu: [x64] os: [win32] + '@hpke/chacha20poly1305@1.8.0': + resolution: {integrity: sha512-FcBfAQ+Y99vMNJP2yrZ9wpL8V0GOwp1+zMyzvc6alasrBygfFjFm1yeUtyADJCu/27C3Lm5mJzx6u7pwg+cX5w==} + engines: {node: '>=16.0.0'} + + '@hpke/common@1.10.1': + resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} + engines: {node: '>=16.0.0'} + + '@hpke/core@1.9.0': + resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==} + engines: {node: '>=16.0.0'} + + '@hpke/dhkem-x25519@1.8.0': + resolution: {integrity: sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==} + engines: {node: '>=16.0.0'} + + '@hpke/dhkem-x448@1.8.0': + resolution: {integrity: sha512-mFfnZfgp4OKkUIS/FKikfUgdnDKRy25ytCKBQiV+N+HbYy3I4v4ZCPBQ69QL+TYmKmCZJeUEnYeS5K+OBRP+Eg==} + engines: {node: '>=16.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -567,18 +593,84 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@msgpack/msgpack@3.1.3': + resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} + engines: {node: '>= 18'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/curves@1.8.0': + resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.0': + resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@noble/hashes@1.7.0': + resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@openzeppelin/contracts@5.6.1': + resolution: {integrity: sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==} + + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.12.3': + resolution: {integrity: sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -713,12 +805,79 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@turnkey/api-key-stamper@0.6.10': + resolution: {integrity: sha512-Eczvn1cFg+4tyTjGxVRH64lE/qYjDbx8ynfrLYU8x6n0+ooD9iujXSAzZnbqQcL690D0lje/VrEeOAAEOcE10A==} + engines: {node: '>=18.0.0'} + + '@turnkey/core@2.5.0': + resolution: {integrity: sha512-QakvwL5NrruoBM3j8kPXjgQGXFnAyxwa4mNE3v6GgX6URv8I9Asq7gkiYKx/1LFPc63Ra0w/Q6fSOEn47wGNbg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@react-native-async-storage/async-storage': ^2.2.0 + '@turnkey/react-native-passkey-stamper': 1.2.19 + react-native-keychain: ^8.1.0 || ^9.2.2 || ^10.0.0 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + '@turnkey/react-native-passkey-stamper': + optional: true + react-native-keychain: + optional: true + + '@turnkey/crypto@2.11.1': + resolution: {integrity: sha512-RGMqkmmxUTA2ipPCL2CohVtALn7kYAcTEpqJqU5iCEbHXinxIH1pPpXVYP+ojEv/lCei2vCXutmRBkb7UzK5fg==} + engines: {node: '>=18.0.0'} + + '@turnkey/encoding@0.6.0': + resolution: {integrity: sha512-IC8qXvy36+iGAeiaVIuJvB35uU2Ld/RAWI/DRTKS+ttBej0GXhOn48Ouu5mlca4jt8ZEuwXmDVv74A8uBQclsA==} + engines: {node: '>=18.0.0'} + + '@turnkey/http@6.1.0': + resolution: {integrity: sha512-nECQCq9qokjH4vVkyVn6XtbszVQ4w6UzgDOGguONcHDRYkFN4MvXkhugkIxr3oFjhWHcdzKAN00cCwPZaWV9HA==} + engines: {node: '>=18.0.0'} + + '@turnkey/iframe-stamper@2.11.1': + resolution: {integrity: sha512-S9BoAIyVBH1BurTKsX/YzjVrEhW6Zn0A/9WPfhhC9YA/9RDELP4wNL3jUcijJWgT9OO6BRbRDUs81FrC0ruLww==} + engines: {node: '>=18.0.0'} + + '@turnkey/indexed-db-stamper@1.3.4': + resolution: {integrity: sha512-AsPZVsacGaIRCK2DAqZPFR8Ou8jpP5fJYgEb/Fq/fMP/cwBA9wNj4MXwWT9zimler2CnFf3l4YmOnrFtw17qMA==} + engines: {node: '>=18.0.0'} + + '@turnkey/sdk-browser@8.1.0': + resolution: {integrity: sha512-DPAdIlPjCJtrtiapSIcCeoXQL0Q1FxtNEsipRhTS1VcURk4V10Rk7vcOaEEPLIe7ZwgxiZ656QLtuCpoy+VXPQ==} + engines: {node: '>=18.0.0'} + + '@turnkey/sdk-server@8.1.0': + resolution: {integrity: sha512-6LH7GaXCOF++LRl65A3EgkRD/ZdoSNrc2TXXO3s4hh019cDRAKwefBLh1g2ngEnzWEBtH/7UUZUzssPpnyj/mw==} + engines: {node: '>=18.0.0'} + + '@turnkey/sdk-types@1.4.0': + resolution: {integrity: sha512-0vra2FyoszSpysJgIBJtiY/1aeRVsGuc16bMwj7e6CwGbh/TnDDbUAM5zPCWEOPqGtQZNDsssJW6q4wSy/x+ag==} + engines: {node: '>=18.0.0'} + + '@turnkey/viem@0.14.34': + resolution: {integrity: sha512-dxvyou7JaUU1KORGXTaR/UwrEvbXhNg4EwpQW2DA8ia5plw/CNQtmx8YjyGYSFWz2gzBxDGybro3fWY+uholSg==} + engines: {node: '>=18.0.0'} + peerDependencies: + viem: ^1.16.6 || ^2.24.2 + + '@turnkey/wallet-stamper@1.1.22': + resolution: {integrity: sha512-5vvifKGXiMJnHsk4aZTWct41EBE6JFH7cSUcNiVci6InTdMHZqzzH7awcyW0z8cpBhjYQdz7yRM1oac5FmIYWg==} + + '@turnkey/webauthn-stamper@0.6.0': + resolution: {integrity: sha512-jdN17QEnn7RBykEOhtKIialWmDjnDAH8DzbyITwn8jsKcwT1TBNYge89hTUTjbdsDLBAqQw8cHujPdy0RaAqvw==} + engines: {node: '>=18.0.0'} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -763,6 +922,77 @@ packages: typescript: optional: true + '@wallet-standard/app@1.1.1': + resolution: {integrity: sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==} + engines: {node: '>=22'} + + '@wallet-standard/base@1.1.1': + resolution: {integrity: sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==} + engines: {node: '>=22'} + + '@walletconnect/core@2.23.10': + resolution: {integrity: sha512-Qq2btHEoCgruvkZCWLSrVsvg/dYbM9Z045qeClwhJR4meL32jbIRT0mKWjf0HkRc2LA82MsnszVnfuZl3yWl5A==} + engines: {node: '>=18.20.8'} + + '@walletconnect/environment@1.0.1': + resolution: {integrity: sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==} + + '@walletconnect/events@1.0.1': + resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} + + '@walletconnect/heartbeat@1.2.2': + resolution: {integrity: sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==} + + '@walletconnect/jsonrpc-provider@1.0.14': + resolution: {integrity: sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==} + + '@walletconnect/jsonrpc-types@1.0.4': + resolution: {integrity: sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==} + + '@walletconnect/jsonrpc-utils@1.0.8': + resolution: {integrity: sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==} + + '@walletconnect/jsonrpc-ws-connection@1.0.16': + resolution: {integrity: sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==} + + '@walletconnect/keyvaluestorage@1.1.1': + resolution: {integrity: sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==} + peerDependencies: + '@react-native-async-storage/async-storage': 1.x + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@walletconnect/logger@3.0.2': + resolution: {integrity: sha512-7wR3wAwJTOmX4gbcUZcFMov8fjftY05+5cO/d4cpDD8wDzJ+cIlKdYOXaXfxHLSYeDazMXIsxMYjHYVDfkx+nA==} + + '@walletconnect/relay-api@1.0.11': + resolution: {integrity: sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==} + + '@walletconnect/relay-auth@1.1.0': + resolution: {integrity: sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==} + + '@walletconnect/safe-json@1.0.2': + resolution: {integrity: sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==} + + '@walletconnect/sign-client@2.23.10': + resolution: {integrity: sha512-vO7DGRRmKo+rykmjVyQR1aM4I2nbk9kJ6olbxgjFRR6Jdhy+Kz+zgN7Ce5xVhPfWYVu4bV/XhOQxhvnQw7S5ng==} + + '@walletconnect/time@1.0.2': + resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} + + '@walletconnect/types@2.23.10': + resolution: {integrity: sha512-XP8d41979anTrc1OJF3ISF+g81cvp1wim+ObdNnbcaT/jhwLwv+0T7rRe9VwRv+h8EaRgLyeb5YGy7oJ49vxVg==} + + '@walletconnect/utils@2.23.10': + resolution: {integrity: sha512-b1c9FRF2g7vNnz66oLW5WZD2VCMrbu9xhpmwJJwqGarBiGW7cY8NbUtS9/w2/qc0vsBVKJ/bzDn4TGjpELU6aQ==} + + '@walletconnect/window-getters@1.0.1': + resolution: {integrity: sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==} + + '@walletconnect/window-metadata@1.0.1': + resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -779,13 +1009,49 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + blakejs@1.2.1: + resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} + + borsh@2.0.0: + resolution: {integrity: sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -796,6 +1062,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cbor-js@0.1.0: + resolution: {integrity: sha512-7sQ/TvDZPl7csT1Sif9G0+MA0I0JOVah8+wWlJVQdVEgIbCzlN/ab3x+uvMNsc34TUvO6osQTAmB2ls80JX6tw==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -808,6 +1077,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -819,6 +1092,18 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -832,6 +1117,15 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-browser@5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -839,6 +1133,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -857,9 +1154,17 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} + engines: {node: '>=14.0.0'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -881,13 +1186,26 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hono@4.12.29: resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} engines: {node: '>=16.9.0'} + hpke-js@1.8.0: + resolution: {integrity: sha512-N0PFQlUQsIPS9++nUNn2ZsxTPSv8pONyyrXIGZl0iiherRfS0XW1SvTd+RmepD0TN1S9zzTJkEutMIWWYt0/4w==} + engines: {node: '>=16.0.0'} + idb-keyval@6.3.0: resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -897,6 +1215,13 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + keyvaluestorage-interface@1.0.0: + resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -911,6 +1236,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -928,6 +1257,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -936,10 +1268,36 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + ox@0.14.30: resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==} peerDependencies: @@ -956,6 +1314,14 @@ packages: typescript: optional: true + ox@0.9.3: + resolution: {integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -969,10 +1335,24 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.0.0: + resolution: {integrity: sha512-eI9pKwWEix40kfvSzqEP6ldqOoBIN7dwD/o91TY5z8vQI12sAffpR/pOqAD1IVVwIVHDpHjkq0joBPdJD0rafA==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1034,10 +1414,37 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1047,9 +1454,22 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + sha256-uint8array@0.10.7: + resolution: {integrity: sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + slow-redact@0.3.2: + resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1058,6 +1478,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1076,6 +1500,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1098,6 +1525,9 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1105,6 +1535,15 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -1129,6 +1568,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1137,9 +1580,84 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uint8arrays@3.1.1: + resolution: {integrity: sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + viem@2.54.6: resolution: {integrity: sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==} peerDependencies: @@ -1209,11 +1727,29 @@ packages: jsdom: optional: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -1551,6 +2087,24 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hpke/chacha20poly1305@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/common@1.10.1': {} + + '@hpke/core@1.9.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/dhkem-x25519@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/dhkem-x448@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1565,14 +2119,132 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpack/msgpack@3.1.3': {} + '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/curves@1.8.0': + dependencies: + '@noble/hashes': 1.7.0 + + '@noble/curves@1.9.0': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.3.2': {} + + '@noble/hashes@1.7.0': {} + '@noble/hashes@1.8.0': {} + '@openzeppelin/contracts@5.6.1': {} + + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.12.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -1661,12 +2333,190 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@turnkey/api-key-stamper@0.6.10': + dependencies: + '@noble/curves': 1.9.1 + '@turnkey/crypto': 2.11.1 + '@turnkey/encoding': 0.6.0 + sha256-uint8array: 0.10.7 + + '@turnkey/core@2.5.0(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@turnkey/api-key-stamper': 0.6.10 + '@turnkey/crypto': 2.11.1 + '@turnkey/encoding': 0.6.0 + '@turnkey/http': 6.1.0 + '@turnkey/sdk-types': 1.4.0 + '@turnkey/webauthn-stamper': 0.6.0 + '@wallet-standard/app': 1.1.1 + '@wallet-standard/base': 1.1.1 + '@walletconnect/sign-client': 2.23.10(typescript@5.9.3)(zod@4.4.3) + '@walletconnect/types': 2.23.10 + cross-fetch: 3.2.0 + ethers: 6.17.0 + jwt-decode: 4.0.0 + uuid: 11.1.1 + viem: 2.54.6(typescript@5.9.3)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@turnkey/crypto@2.11.1': + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.0 + '@noble/hashes': 1.8.0 + '@peculiar/x509': 1.12.3 + '@turnkey/encoding': 0.6.0 + '@turnkey/sdk-types': 1.4.0 + borsh: 2.0.0 + cbor-js: 0.1.0 + + '@turnkey/encoding@0.6.0': + dependencies: + bs58: 6.0.0 + bs58check: 4.0.0 + + '@turnkey/http@6.1.0': + dependencies: + '@turnkey/api-key-stamper': 0.6.10 + '@turnkey/encoding': 0.6.0 + '@turnkey/webauthn-stamper': 0.6.0 + cross-fetch: 3.2.0 + transitivePeerDependencies: + - encoding + + '@turnkey/iframe-stamper@2.11.1': {} + + '@turnkey/indexed-db-stamper@1.3.4': + dependencies: + '@turnkey/encoding': 0.6.0 + '@turnkey/sdk-types': 1.4.0 + + '@turnkey/sdk-browser@8.1.0(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@turnkey/api-key-stamper': 0.6.10 + '@turnkey/crypto': 2.11.1 + '@turnkey/encoding': 0.6.0 + '@turnkey/http': 6.1.0 + '@turnkey/iframe-stamper': 2.11.1 + '@turnkey/indexed-db-stamper': 1.3.4 + '@turnkey/sdk-types': 1.4.0 + '@turnkey/wallet-stamper': 1.1.22(typescript@5.9.3)(zod@4.4.3) + '@turnkey/webauthn-stamper': 0.6.0 + buffer: 6.0.3 + cross-fetch: 3.2.0 + hpke-js: 1.8.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@turnkey/sdk-server@8.1.0(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@turnkey/api-key-stamper': 0.6.10 + '@turnkey/http': 6.1.0 + '@turnkey/sdk-types': 1.4.0 + '@turnkey/wallet-stamper': 1.1.22(typescript@5.9.3)(zod@4.4.3) + buffer: 6.0.3 + cross-fetch: 3.2.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@turnkey/sdk-types@1.4.0': {} + + '@turnkey/viem@0.14.34(typescript@5.9.3)(viem@2.54.6(typescript@5.9.3)(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@noble/curves': 1.8.0 + '@openzeppelin/contracts': 5.6.1 + '@turnkey/api-key-stamper': 0.6.10 + '@turnkey/core': 2.5.0(typescript@5.9.3)(zod@4.4.3) + '@turnkey/http': 6.1.0 + '@turnkey/sdk-browser': 8.1.0(typescript@5.9.3)(zod@4.4.3) + '@turnkey/sdk-server': 8.1.0(typescript@5.9.3)(zod@4.4.3) + cross-fetch: 4.1.0 + viem: 2.54.6(typescript@5.9.3)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@turnkey/react-native-passkey-stamper' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react-native-keychain + - typescript + - uploadthing + - utf-8-validate + - zod + + '@turnkey/wallet-stamper@1.1.22(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@turnkey/crypto': 2.11.1 + '@turnkey/encoding': 0.6.0 + optionalDependencies: + viem: 2.54.6(typescript@5.9.3)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@turnkey/webauthn-stamper@0.6.0': + dependencies: + sha256-uint8array: 0.10.7 + '@types/estree@1.0.9': {} '@types/node@20.19.43': dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1721,6 +2571,264 @@ snapshots: - react - use-sync-external-store + '@wallet-standard/app@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + + '@wallet-standard/base@1.1.1': {} + + '@walletconnect/core@2.23.10(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.23.10 + '@walletconnect/utils': 2.23.10(typescript@5.9.3)(zod@4.4.3) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.45.1 + events: 3.3.0 + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/environment@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/events@1.0.1': + dependencies: + keyvaluestorage-interface: 1.0.0 + tslib: 1.14.1 + + '@walletconnect/heartbeat@1.2.2': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/time': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-provider@1.0.14': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-types@1.0.4': + dependencies: + events: 3.3.0 + keyvaluestorage-interface: 1.0.0 + + '@walletconnect/jsonrpc-utils@1.0.8': + dependencies: + '@walletconnect/environment': 1.0.1 + '@walletconnect/jsonrpc-types': 1.0.4 + tslib: 1.14.1 + + '@walletconnect/jsonrpc-ws-connection@1.0.16': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@walletconnect/keyvaluestorage@1.1.1': + dependencies: + '@walletconnect/safe-json': 1.0.2 + idb-keyval: 6.3.0 + unstorage: 1.17.5(idb-keyval@6.3.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/logger@3.0.2': + dependencies: + '@walletconnect/safe-json': 1.0.2 + pino: 10.0.0 + + '@walletconnect/relay-api@1.0.11': + dependencies: + '@walletconnect/jsonrpc-types': 1.0.4 + + '@walletconnect/relay-auth@1.1.0': + dependencies: + '@noble/curves': 1.8.0 + '@noble/hashes': 1.7.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + uint8arrays: 3.1.1 + + '@walletconnect/safe-json@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/sign-client@2.23.10(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@walletconnect/core': 2.23.10(typescript@5.9.3)(zod@4.4.3) + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 3.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.23.10 + '@walletconnect/utils': 2.23.10(typescript@5.9.3)(zod@4.4.3) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/time@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/types@2.23.10': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/utils@2.23.10(typescript@5.9.3)(zod@4.4.3)': + dependencies: + '@msgpack/msgpack': 3.1.3 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.23.10 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + blakejs: 1.2.1 + detect-browser: 5.3.0 + ox: 0.9.3(typescript@5.9.3)(zod@4.4.3) + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - typescript + - uploadthing + - zod + + '@walletconnect/window-getters@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/window-metadata@1.0.1': + dependencies: + '@walletconnect/window-getters': 1.0.1 + tslib: 1.14.1 + abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): optionalDependencies: typescript: 5.9.3 @@ -1728,10 +2836,47 @@ snapshots: acorn@8.17.0: {} + aes-js@4.0.0-beta.5: {} + any-promise@1.3.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + + base-x@5.0.1: {} + + base64-js@1.5.1: {} + + blakejs@1.2.1: {} + + borsh@2.0.0: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1739,6 +2884,8 @@ snapshots: cac@6.7.14: {} + cbor-js@0.1.0: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1753,22 +2900,52 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + commander@4.1.1: {} confbox@0.1.8: {} consola@3.4.2: {} + cookie-es@1.2.3: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 deep-eql@5.0.2: {} + defu@6.1.7: {} + + destr@2.0.5: {} + + detect-browser@5.3.0: {} + dotenv@16.6.1: {} es-module-lexer@1.7.0: {} + es-toolkit@1.45.1: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1857,8 +3034,23 @@ snapshots: dependencies: '@types/estree': 1.0.9 + ethers@6.17.0: + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventemitter3@5.0.1: {} + events@3.3.0: {} + expect-type@1.4.0: {} fdir@6.5.0(picomatch@4.0.5): @@ -1874,16 +3066,44 @@ snapshots: fsevents@2.3.3: optional: true + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + hono@4.12.29: {} + hpke-js@1.8.0: + dependencies: + '@hpke/chacha20poly1305': 1.8.0 + '@hpke/common': 1.10.1 + '@hpke/core': 1.9.0 + '@hpke/dhkem-x25519': 1.8.0 + '@hpke/dhkem-x448': 1.8.0 + idb-keyval@6.3.0: {} + ieee754@1.2.1: {} + + iron-webcrypto@1.2.1: {} + isows@1.0.7(ws@8.21.0): dependencies: ws: 8.21.0 joycon@3.1.1: {} + jwt-decode@4.0.0: {} + + keyvaluestorage-interface@1.0.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -1892,6 +3112,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1909,6 +3131,8 @@ snapshots: ms@2.1.3: {} + multiformats@9.9.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -1917,8 +3141,26 @@ snapshots: nanoid@3.3.15: {} + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + object-assign@4.1.1: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + on-exit-leak-free@2.1.2: {} + ox@0.14.30(typescript@5.9.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -1949,6 +3191,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.9.3(typescript@5.9.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + pathe@1.1.2: {} pathe@2.0.3: {} @@ -1957,8 +3214,30 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.5: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.0.0: + dependencies: + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + slow-redact: 0.3.2 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -1997,8 +3276,26 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + process-warning@5.1.0: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + + quick-format-unescaped@4.0.4: {} + + radix3@1.1.2: {} + readdirp@4.1.2: {} + readdirp@5.1.1: {} + + real-require@0.2.0: {} + + reflect-metadata@0.2.2: {} + resolve-from@5.0.0: {} rollup@4.62.2: @@ -2032,12 +3329,24 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-stable-stringify@2.5.0: {} + + sha256-uint8array@0.10.7: {} + siginfo@2.0.0: {} + slow-redact@0.3.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} + split2@4.2.0: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -2060,6 +3369,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2075,10 +3388,18 @@ snapshots: tinyspy@3.0.2: {} + tr46@0.0.3: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + + tslib@2.7.0: {} + + tslib@2.8.1: {} + tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) @@ -2113,12 +3434,39 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + typescript@5.9.3: {} ufo@1.6.4: {} + uint8arrays@3.1.1: + dependencies: + multiformats: 9.9.0 + + uncrypto@0.1.3: {} + + undici-types@6.19.8: {} + undici-types@6.21.0: {} + unstorage@1.17.5(idb-keyval@6.3.0): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + optionalDependencies: + idb-keyval: 6.3.0 + + uuid@11.1.1: {} + viem@2.54.6(typescript@5.9.3)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 @@ -2198,11 +3546,20 @@ snapshots: - supports-color - terser + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + ws@7.5.13: {} + ws@8.21.0: {} zod@4.4.3: {} diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 054800e..37f5133 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -77,6 +77,9 @@ export { AltanaWalletProvider } from "./wallets/altana/index.js"; // Self-broadcasting TWAK (Trust Wallet Agent Kit CLI) wallet — twak is an // npm CLI; install @trustwallet/cli and the provider finds the local bin. export { TWAKProvider } from "./wallets/twak/index.js"; +// Pure-signer Turnkey wallet (remote signing, keys in AWS Nitro enclaves). +// The backing @turnkey/* peers are optional and loaded lazily on first use. +export { TurnkeyWalletProvider } from "./wallets/turnkey/index.js"; // ERC-8183 — only essential public API export { ERC8183Client } from "./erc8183/client.js"; diff --git a/typescript/src/wallets/index.ts b/typescript/src/wallets/index.ts index 350e51a..b2f609a 100644 --- a/typescript/src/wallets/index.ts +++ b/typescript/src/wallets/index.ts @@ -3,6 +3,7 @@ export * from "./capabilities.js"; export * from "./errors.js"; export * from "./evmWalletProvider.js"; export * from "./intents.js"; +export * from "./turnkey/index.js"; export * from "./twak/index.js"; export * from "./keystore.js"; export * from "./localExecutor.js"; diff --git a/typescript/src/wallets/turnkey/index.ts b/typescript/src/wallets/turnkey/index.ts new file mode 100644 index 0000000..64426e7 --- /dev/null +++ b/typescript/src/wallets/turnkey/index.ts @@ -0,0 +1,22 @@ +/** + * Turnkey wallet provider surface. + * + * Note the deliberate omission: the internal SDK-module mirrors + * (`TurnkeySdkModules` and friends in `./types.js`) stay out of the barrel — + * they type the lazy `@turnkey/*` boundary, not the public API. + */ + +export { + TURNKEY_API_BASE_URL_DEFAULT, + TurnkeyWalletProvider, +} from "./provider.js"; +export type { + TurnkeyFromEnvOptions, + TurnkeyWalletProviderOptions, +} from "./provider.js"; +export { + TURNKEY_SDK_SERVER_PACKAGE, + TURNKEY_VIEM_PACKAGE, + setTurnkeySdkImporter, +} from "./sdkLoader.js"; +export type { TurnkeyPackageName, TurnkeySdkImporter } from "./sdkLoader.js"; diff --git a/typescript/src/wallets/turnkey/provider.ts b/typescript/src/wallets/turnkey/provider.ts new file mode 100644 index 0000000..83676b1 --- /dev/null +++ b/typescript/src/wallets/turnkey/provider.ts @@ -0,0 +1,408 @@ +/** + * Turnkey Wallet Provider — remote signing, keys in AWS Nitro Enclaves. + * + * A pure signer over Turnkey's hosted key-management API + * (https://docs.turnkey.com): the private key is generated and held inside + * Turnkey's enclave and never leaves it; every sign call is an + * authenticated HTTPS round-trip stamped by a locally held P-256 API key + * pair. No key material ever exists on this machine. + * + * Operational constraints that shaped this implementation: + * + * - **Every successful signature is billed** (free tier: 25/month at + * 1 request/second; pay-as-you-go $0.10/signature). All client-side + * guards (SigningPolicy, chain-id pinning, input validation) run BEFORE + * the API call so a refusal never costs quota. + * - **Root API keys bypass ALL Turnkey server-side policies** (root + * quorum). Production deployments must use a non-root API user plus an + * explicit ALLOW policy; this cannot be detected client-side. + * - **Broadcast is not included** (managed broadcast is a paid feature). + * The provider only signs; the default `LocalExecutor` broadcasts over + * the SDK's own RPC, and the MegaFuel paymaster path works unchanged. + * - **EIP-712 domain-stripping trap** (`@turnkey/viem` ≤0.14.34, probe + * finding 2026-07-24): when the `types` object passed to the Turnkey + * account lacks an explicit `EIP712Domain` entry, viem's + * `serializeTypedData` silently serializes the domain as `{}` — the + * signature succeeds, is billed, and binds an EMPTY domain (unverifiable + * / replayable across domains). {@link TurnkeyWalletProvider.signTypedData} + * therefore always injects the full `EIP712Domain` type into the enclave + * payload. The injection is idempotent, so it stays safe if upstream + * fixes the default. + */ + +import type { + LocalAccount, + TransactionSerializable, + TypedDataDomain, +} from "viem"; +import { + getAddress, + getTypesForEIP712Domain, + hashMessage, + hashTypedData, + keccak256, + parseSignature, + parseTransaction, +} from "viem"; +import { getEnv } from "../../core/envUtil.js"; +import { check, inferPrimaryType } from "../../signing/checks.js"; +import { SigningPolicy } from "../../signing/policy.js"; +import { CALLS_ARBITRARY, PAYMASTER_SPONSOR } from "../capabilities.js"; +import { WalletIdentityMismatch } from "../errors.js"; +import type { + SignableTransaction, + SignatureResult, + SignedTx, +} from "../walletProvider.js"; +import { WalletProvider } from "../walletProvider.js"; +import { loadTurnkeySdk } from "./sdkLoader.js"; + +/** Default Turnkey API host. */ +export const TURNKEY_API_BASE_URL_DEFAULT = "https://api.turnkey.com"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +/** Constructor options for {@link TurnkeyWalletProvider}. */ +export interface TurnkeyWalletProviderOptions { + /** Turnkey organization id (dashboard → settings). */ + organizationId: string; + /** + * The wallet account to sign with — MUST be the account's Ethereum + * address (`0x` + 40 hex chars), not a Turnkey wallet id or private-key + * id. Constraining to the address keeps {@link TurnkeyWalletProvider.address} + * synchronous and avoids a lookup round-trip. + */ + signWith: string; + /** P-256 API key public component (dashboard → API keys). */ + apiPublicKey: string; + /** P-256 API key private component. A client credential — never leaves this process. */ + apiPrivateKey: string; + /** API host override (default {@link TURNKEY_API_BASE_URL_DEFAULT}). */ + apiBaseUrl?: string; + /** + * When set, {@link TurnkeyWalletProvider.signTransaction} refuses any + * transaction whose `chainId` differs — fail-closed BEFORE the billable + * API call. + */ + expectedChainId?: number; + /** + * Policy applied to every {@link TurnkeyWalletProvider.signTypedData} + * call, BEFORE the billable API call. Defaults to + * {@link SigningPolicy.strictDefault}. This client-side gate is the first + * of two layers — Turnkey's server-side policy engine is the second (and + * is bypassed entirely for root API users). + */ + signingPolicy?: SigningPolicy; +} + +/** Options accepted by {@link TurnkeyWalletProvider.fromEnv}. */ +export interface TurnkeyFromEnvOptions { + expectedChainId?: number; + signingPolicy?: SigningPolicy; +} + +/** + * Rewrite recognizable Turnkey API failures into actionable errors. + * + * Detection is intentionally string/shape-based (the SDK error classes live + * in the optional peer, which may not be installed at type-check time). + * Unrecognized errors pass through untouched. + */ +function mapTurnkeyError(op: string, error: unknown): unknown { + const message = error instanceof Error ? error.message : String(error); + const status = (error as { status?: unknown } | null)?.status; + if (/quota/i.test(message)) { + return new Error( + `Turnkey signature quota exhausted during ${op} (free tier: 25 billed signatures/month; pay-as-you-go $0.10/signature) — check the Turnkey billing dashboard.`, + { cause: error }, + ); + } + if (status === 429 || /rate.?limit/i.test(message)) { + return new Error( + `Turnkey rate limit hit during ${op} (free tier allows 1 request/second) — pace calls or upgrade the plan.`, + { cause: error }, + ); + } + if (/policy/i.test(message) && /denied|reject/i.test(message)) { + return new Error( + `Turnkey server-side policy denied ${op} — verify the API user has an explicit ALLOW policy covering this operation (non-root users are default-deny).`, + { cause: error }, + ); + } + return error; +} + +/** + * Wallet provider backed by Turnkey's remote enclave signing service. + * + * A pure signer: implements the three `sign*` methods (capabilities derive + * automatically) and inherits the default `LocalExecutor` path, so ERC-8004 + * / ERC-8183 writes, x402 payments (via `X402Signer`) and MegaFuel + * sponsorship all work without Turnkey-specific wiring. + * + * Construction is cheap and offline — the Turnkey packages load and the + * signing account is built lazily on the first sign call. + * + * ```ts + * const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 }); + * const sig = await wallet.signMessage("hello"); // 1 billed signature + * ``` + */ +export class TurnkeyWalletProvider extends WalletProvider { + static override readonly kind: string = "turnkey"; + + // Arbitrary mechanical contract calls via LocalExecutor; sponsored + // broadcast via the MegaFuel paymaster (gasPrice=0 legacy signing verified + // against the enclave, probe 2026-07-27). sign.* derive automatically + // since all three sign methods below are overridden. + protected override readonly extraCapabilities: ReadonlySet = new Set([ + CALLS_ARBITRARY, + PAYMASTER_SPONSOR, + ]); + + readonly #address: `0x${string}`; + readonly #organizationId: string; + readonly #apiPublicKey: string; + readonly #apiPrivateKey: string; + readonly #apiBaseUrl: string; + readonly #expectedChainId: number | undefined; + readonly #signingPolicy: SigningPolicy; + + #accountPromise: Promise | null = null; + + constructor(opts: TurnkeyWalletProviderOptions) { + super(); + for (const [key, value] of [ + ["organizationId", opts.organizationId], + ["signWith", opts.signWith], + ["apiPublicKey", opts.apiPublicKey], + ["apiPrivateKey", opts.apiPrivateKey], + ] as const) { + if (!value) { + throw new Error(`TurnkeyWalletProvider: '${key}' is required`); + } + } + if (!ADDRESS_RE.test(opts.signWith)) { + throw new Error( + `TURNKEY_SIGN_WITH must be the wallet account's Ethereum address (0x + 40 hex chars), not a Turnkey wallet id or private-key id — copy the address from the Turnkey dashboard wallet-account view. Got: '${opts.signWith}'`, + ); + } + this.#address = getAddress(opts.signWith); + this.#organizationId = opts.organizationId; + this.#apiPublicKey = opts.apiPublicKey; + this.#apiPrivateKey = opts.apiPrivateKey; + this.#apiBaseUrl = opts.apiBaseUrl || TURNKEY_API_BASE_URL_DEFAULT; + this.#expectedChainId = opts.expectedChainId; + this.#signingPolicy = opts.signingPolicy ?? SigningPolicy.strictDefault(); + } + + /** + * Build a provider from the `TURNKEY_*` environment variables: + * `TURNKEY_API_PUBLIC_KEY`, `TURNKEY_API_PRIVATE_KEY`, `TURNKEY_ORG_ID`, + * `TURNKEY_SIGN_WITH` (required) and `TURNKEY_API_BASE_URL` (optional). + */ + static fromEnv(opts: TurnkeyFromEnvOptions = {}): TurnkeyWalletProvider { + const values = { + TURNKEY_API_PUBLIC_KEY: getEnv("TURNKEY_API_PUBLIC_KEY"), + TURNKEY_API_PRIVATE_KEY: getEnv("TURNKEY_API_PRIVATE_KEY"), + TURNKEY_ORG_ID: getEnv("TURNKEY_ORG_ID"), + TURNKEY_SIGN_WITH: getEnv("TURNKEY_SIGN_WITH"), + }; + const missing = Object.entries(values) + .filter(([, v]) => !v) + .map(([k]) => k); + if (missing.length > 0) { + throw new Error( + `TurnkeyWalletProvider.fromEnv: missing required env vars: ${missing.join(", ")}. The values come from the Turnkey dashboard (API keys, organization settings, wallet account address).`, + ); + } + return new TurnkeyWalletProvider({ + apiPublicKey: values.TURNKEY_API_PUBLIC_KEY as string, + apiPrivateKey: values.TURNKEY_API_PRIVATE_KEY as string, + organizationId: values.TURNKEY_ORG_ID as string, + signWith: values.TURNKEY_SIGN_WITH as string, + apiBaseUrl: getEnv("TURNKEY_API_BASE_URL"), + ...(opts.expectedChainId !== undefined + ? { expectedChainId: opts.expectedChainId } + : {}), + ...(opts.signingPolicy ? { signingPolicy: opts.signingPolicy } : {}), + }); + } + + get address(): `0x${string}` { + return this.#address; + } + + override get keyLocation(): string { + return `remote:turnkey (${this.#apiBaseUrl}; key held in AWS Nitro enclave, never leaves)`; + } + + /** The SigningPolicy currently enforcing {@link signTypedData} calls. */ + get signingPolicy(): SigningPolicy { + return this.#signingPolicy; + } + + /** The chain id this provider is pinned to, if any. */ + get expectedChainId(): number | undefined { + return this.#expectedChainId; + } + + /** + * Lazily build (and cache) the Turnkey-backed viem account. Concurrent + * first callers share one in-flight promise; a rejection clears the cache + * so a transient API failure is retryable on the next call. + */ + #account(): Promise { + if (!this.#accountPromise) { + this.#accountPromise = this.#initAccount().then( + (account) => account, + (error: unknown) => { + this.#accountPromise = null; + throw error; + }, + ); + } + return this.#accountPromise; + } + + async #initAccount(): Promise { + const { sdkServer, viem } = await loadTurnkeySdk(); + const turnkey = new sdkServer.Turnkey({ + apiBaseUrl: this.#apiBaseUrl, + apiPublicKey: this.#apiPublicKey, + apiPrivateKey: this.#apiPrivateKey, + defaultOrganizationId: this.#organizationId, + }); + const account = await viem.createAccount({ + client: turnkey.apiClient(), + organizationId: this.#organizationId, + signWith: this.#address, + }); + const actual = getAddress(account.address); + if (actual !== this.#address) { + throw new WalletIdentityMismatch({ + expected: this.#address, + actual, + }); + } + return account; + } + + /** Run a billable account call with Turnkey-aware error rewriting. */ + async #vendor(op: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + throw mapTurnkeyError(op, error); + } + } + + /** + * Sign a message using EIP-191 personal sign. + * + * The digest is hashed locally and blind-signed by the enclave + * (`HEXADECIMAL` + `NO_OP`), so Turnkey's server-side policies cannot see + * the message content — content-level control lives in this SDK's + * client-side policy layer. + */ + override async signMessage(message: string): Promise { + const account = await this.#account(); + const signature = await this.#vendor("signMessage", () => + account.signMessage({ message }), + ); + const { r, s, v } = parseSignature(signature); + return { + messageHash: hashMessage(message), + r, + s, + v: v as bigint, + signature, + }; + } + + /** + * Sign EIP-712 typed data after passing the configured + * {@link SigningPolicy} — the policy check runs BEFORE the billable API + * call, so a refusal costs no quota. + * + * The full typed-data document goes to the enclave + * (`PAYLOAD_ENCODING_EIP712`), where server-side policies can filter on + * `eth.eip_712.domain` / `primary_type` / `message`. + */ + override async signTypedData( + domain: TypedDataDomain, + types: Record, + message: Record, + ): Promise { + check( + this.#signingPolicy, + domain as Record, + types, + message, + ); + // Drop a caller-supplied EIP712Domain for hashing parity with the other + // providers (identical signatures whether or not it was supplied) … + const messageTypes = Object.fromEntries( + Object.entries(types).filter(([k]) => k !== "EIP712Domain"), + ); + const primaryType = inferPrimaryType(types); + // … then inject the full domain type into the enclave payload: + // @turnkey/viem (≤0.14.34) serializes the domain as {} when the types + // object lacks an explicit EIP712Domain entry — the signature would + // bind an empty domain. See the module docstring. + const fullTypes = { + EIP712Domain: getTypesForEIP712Domain({ domain }), + ...messageTypes, + }; + const account = await this.#account(); + const signature = await this.#vendor("signTypedData", () => + account.signTypedData({ + domain, + types: fullTypes, + primaryType, + message, + } as Parameters[0]), + ); + const { r, s, v } = parseSignature(signature); + const messageHash = hashTypedData({ + domain, + types: messageTypes, + primaryType, + message, + } as Parameters[0]); + return { messageHash, r, s, v: v as bigint, signature }; + } + + /** + * Sign a transaction (legacy or EIP-1559 — the serializer infers the type + * from the fee fields; both shapes are enclave-verified). + * + * When the provider was constructed with `expectedChainId`, a mismatching + * `tx.chainId` is refused before the billable API call. + */ + override async signTransaction(tx: SignableTransaction): Promise { + if ( + this.#expectedChainId !== undefined && + tx.chainId !== this.#expectedChainId + ) { + throw new Error( + `Refusing to sign for chainId=${tx.chainId}: this Turnkey provider is pinned to chainId=${this.#expectedChainId} (every Turnkey signature is billed, so the mismatch fails closed before the API call).`, + ); + } + const account = await this.#account(); + const rawTransaction = await this.#vendor("signTransaction", () => + account.signTransaction(tx as unknown as TransactionSerializable), + ); + const parsed = parseTransaction(rawTransaction); + const v = + parsed.v ?? + (parsed.yParity !== undefined ? BigInt(parsed.yParity) + 27n : 0n); + return { + rawTransaction, + hash: keccak256(rawTransaction), + r: parsed.r as `0x${string}`, + s: parsed.s as `0x${string}`, + v, + }; + } +} diff --git a/typescript/src/wallets/turnkey/sdkLoader.ts b/typescript/src/wallets/turnkey/sdkLoader.ts new file mode 100644 index 0000000..b6b7f32 --- /dev/null +++ b/typescript/src/wallets/turnkey/sdkLoader.ts @@ -0,0 +1,94 @@ +/** + * Lazy loader for the optional `@turnkey/sdk-server` + `@turnkey/viem` peer + * dependencies. + * + * Turnkey is a remote signing service; its SDK packages are declared as + * *optional* peerDependencies and this dynamic `import()` pair is the SDK's + * ONLY runtime coupling point to them: nothing Turnkey-related loads until a + * `TurnkeyWalletProvider` actually needs the backend, and consumers who + * never touch Turnkey never need the packages installed. + * + * The provider always needs both packages together (`@turnkey/sdk-server` + * for the authenticated API client, `@turnkey/viem` for the signing + * account), so they load behind a single seam and cache as one unit. + */ + +import type { TurnkeySdkModules } from "./types.js"; + +/** The npm package providing the authenticated Turnkey API client. */ +export const TURNKEY_SDK_SERVER_PACKAGE = "@turnkey/sdk-server"; + +/** The npm package providing the viem signing-account adapter. */ +export const TURNKEY_VIEM_PACKAGE = "@turnkey/viem"; + +/** One of the two optional Turnkey packages the provider binds to. */ +export type TurnkeyPackageName = + | typeof TURNKEY_SDK_SERVER_PACKAGE + | typeof TURNKEY_VIEM_PACKAGE; + +/** Host-supplied loader for the optional Turnkey SDK packages. */ +export type TurnkeySdkImporter = (pkg: TurnkeyPackageName) => Promise; + +const realImporter: TurnkeySdkImporter = (pkg) => import(pkg); + +let importTurnkeyModule: TurnkeySdkImporter = realImporter; +let cachedModules: Promise | null = null; + +/** Whether `error` is any runtime's flavor of "that module isn't installed". */ +function isModuleNotFound(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return ( + /cannot find (module|package)/i.test(message) && + (message.includes(TURNKEY_SDK_SERVER_PACKAGE) || + message.includes(TURNKEY_VIEM_PACKAGE)) + ); +} + +/** + * Import both Turnkey packages, caching the combined module promise for the + * process lifetime. A missing install is rewritten into an actionable error + * naming the exact `pnpm add` to run; the failed promise is NOT cached, so + * the next call retries cleanly. + */ +export async function loadTurnkeySdk(): Promise { + if (!cachedModules) { + cachedModules = Promise.all([ + importTurnkeyModule(TURNKEY_SDK_SERVER_PACKAGE), + importTurnkeyModule(TURNKEY_VIEM_PACKAGE), + ]).then( + ([sdkServer, viem]) => + ({ sdkServer, viem }) as unknown as TurnkeySdkModules, + (error: unknown) => { + cachedModules = null; + if (isModuleNotFound(error)) { + throw new Error( + `The Turnkey wallet provider requires the optional peer dependencies '${TURNKEY_SDK_SERVER_PACKAGE}' and '${TURNKEY_VIEM_PACKAGE}' (not installed). Install them with: pnpm add ${TURNKEY_SDK_SERVER_PACKAGE} ${TURNKEY_VIEM_PACKAGE}`, + { cause: error }, + ); + } + throw error; + }, + ); + } + return cachedModules; +} + +/** + * Swap the dynamic-import implementation, or restore the package-relative + * default with `null`. + * + * Hosts such as a globally installed CLI use this seam to resolve the + * optional Turnkey packages from an agent project's own `node_modules`. + * Changing the importer always clears the process cache so the next Turnkey + * operation uses the newly selected module source. + */ +export function setTurnkeySdkImporter( + importer: TurnkeySdkImporter | null, +): void { + importTurnkeyModule = importer ?? realImporter; + cachedModules = null; +} diff --git a/typescript/src/wallets/turnkey/types.ts b/typescript/src/wallets/turnkey/types.ts new file mode 100644 index 0000000..febe062 --- /dev/null +++ b/typescript/src/wallets/turnkey/types.ts @@ -0,0 +1,70 @@ +/** + * Structural mirrors of the `@turnkey/sdk-server` + `@turnkey/viem` public + * types the provider touches. + * + * Both Turnkey packages are **optional** peer dependencies, so nothing in + * `src/` may import from them at the type level — otherwise the generated + * `.d.ts` bundle would carry `import("@turnkey/…")` references that break + * consumers who never installed the peers. These mirrors keep our public + * declaration files self-contained; `tests/turnkeyTypeCompat.test.ts` pins + * assignability against the real packages (devDependencies) so any drift + * fails `pnpm typecheck` instead of surfacing at runtime. + * + * Mirrored from `@turnkey/sdk-server@8.1.0` (`TurnkeySDKServerConfig`, + * `TurnkeyServerSDK`) and `@turnkey/viem@0.14.34` (`createAccount`). Only + * the slice the provider calls is mirrored — the packages export far more. + */ + +import type { LocalAccount } from "viem"; + +/** + * Constructor config for the Turnkey server SDK (mirrors + * `TurnkeySDKServerConfig`, required fields only). The API key pair is a + * locally held P-256 keypair that stamps (signs) every request body — the + * private key is a client credential, never sent over the wire. + */ +export interface TurnkeyClientConfig { + apiBaseUrl: string; + apiPublicKey: string; + apiPrivateKey: string; + defaultOrganizationId: string; +} + +/** + * Opaque handle for the authenticated Turnkey API client returned by + * `Turnkey#apiClient()`. The provider only threads it through to + * `createAccount`, so no surface is mirrored. + */ +export type TurnkeyApiClient = object; + +/** The `Turnkey` server SDK instance surface the provider uses. */ +export interface TurnkeyServerSdk { + apiClient(): TurnkeyApiClient; +} + +/** Module shape of `@turnkey/sdk-server` (the slice the provider uses). */ +export interface TurnkeySdkServerModule { + Turnkey: new (config: TurnkeyClientConfig) => TurnkeyServerSdk; +} + +/** + * Module shape of `@turnkey/viem` (the slice the provider uses). + * + * `createAccount` returns a standard viem `LocalAccount` whose sign methods + * round-trip through the Turnkey API; with `signWith` set to the account's + * Ethereum address it performs no network call at construction. + */ +export interface TurnkeyViemModule { + createAccount(input: { + client: TurnkeyApiClient; + organizationId: string; + signWith: string; + ethereumAddress?: string; + }): Promise; +} + +/** Both lazily imported Turnkey packages, loaded and cached as one unit. */ +export interface TurnkeySdkModules { + sdkServer: TurnkeySdkServerModule; + viem: TurnkeyViemModule; +} diff --git a/typescript/tests/turnkeyProvider.test.ts b/typescript/tests/turnkeyProvider.test.ts new file mode 100644 index 0000000..c15e391 --- /dev/null +++ b/typescript/tests/turnkeyProvider.test.ts @@ -0,0 +1,653 @@ +/** + * `TurnkeyWalletProvider` conformance (`src/wallets/turnkey/provider.ts`): + * capability surface, synchronous address, lazy account init (concurrency + + * retry), the EIP712Domain injection trap regression, policy-before-billing + * ordering, legacy/1559 transaction round-trips, `fromEnv`, vendor error + * mapping, and the missing-peer install guidance. + * + * `@turnkey/sdk-server` / `@turnkey/viem` are mocked at the module level + * (the provider only ever reaches them through the lazy loader), with + * `createAccount` returning a real viem `privateKeyToAccount` wrapped to + * RECORD the exact call arguments — signatures stay real and recoverable, + * so domain binding is provable with `recoverTypedDataAddress`. + */ + +import { + type TypedDataDomain, + getAddress, + getTypesForEIP712Domain, + hashMessage, + hashTypedData, + keccak256, + parseTransaction, + recoverTypedDataAddress, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PolicyViolation } from "../src/signing/errors.js"; +import { SigningPolicy } from "../src/signing/policy.js"; +import { + CALLS_ARBITRARY, + PAYMASTER_SPONSOR, + SIGN_MESSAGE, + SIGN_TRANSACTION, + SIGN_TYPED_DATA, +} from "../src/wallets/capabilities.js"; +import { WalletIdentityMismatch } from "../src/wallets/errors.js"; + +const tkMocks = vi.hoisted(() => ({ + // Every `new Turnkey(config)` records its config here. + ctorConfigs: [] as Record[], + apiClientCalls: { count: 0 }, + // Every `createAccount(input)` records its input here. + createAccountCalls: [] as Record[], + // Test-swappable behavior; `null` = default faithful account. + createAccountImpl: { + fn: null as null | ((input: unknown) => Promise), + }, + // Recorded arguments of account.sign* calls (the enclave payload). + recorded: { + messages: [] as unknown[], + typedData: [] as Record[], + transactions: [] as unknown[], + }, + // Test-injected failure thrown by the account's sign methods. + signFailure: { error: null as unknown }, + // Counts module-factory executions (module load observability). + factoryRuns: { count: 0 }, +})); + +// The Turnkey-hosted key the mock "enclave" signs with. `SIGN_WITH` is its +// address, so the provider's identity check passes by default. +const TEST_PK: `0x${string}` = `0x${"c3".repeat(32)}`; +const SIGN_WITH = privateKeyToAccount(TEST_PK).address; + +vi.mock("@turnkey/sdk-server", () => { + tkMocks.factoryRuns.count += 1; + class Turnkey { + readonly config: Record; + constructor(config: Record) { + this.config = config; + tkMocks.ctorConfigs.push(config); + } + apiClient(): object { + tkMocks.apiClientCalls.count += 1; + return { __tag: "turnkey-api-client", config: this.config }; + } + } + return { Turnkey }; +}); + +vi.mock("@turnkey/viem", () => { + tkMocks.factoryRuns.count += 1; + return { + createAccount: async (input: Record) => { + tkMocks.createAccountCalls.push(input); + if (tkMocks.createAccountImpl.fn) { + return tkMocks.createAccountImpl.fn(input); + } + const backing = privateKeyToAccount(TEST_PK); + const failing = () => { + if (tkMocks.signFailure.error !== null) { + throw tkMocks.signFailure.error; + } + }; + return { + ...backing, + signMessage: (args: unknown) => { + failing(); + tkMocks.recorded.messages.push(args); + return backing.signMessage( + args as Parameters[0], + ); + }, + signTypedData: (args: Record) => { + failing(); + tkMocks.recorded.typedData.push(args); + return backing.signTypedData( + args as Parameters[0], + ); + }, + signTransaction: (args: unknown) => { + failing(); + tkMocks.recorded.transactions.push(args); + return backing.signTransaction( + args as Parameters[0], + ); + }, + }; + }, + }; +}); + +const { TURNKEY_API_BASE_URL_DEFAULT, TurnkeyWalletProvider } = await import( + "../src/wallets/turnkey/provider.js" +); +const { + TURNKEY_SDK_SERVER_PACKAGE, + TURNKEY_VIEM_PACKAGE, + loadTurnkeySdk, + setTurnkeySdkImporter, +} = await import("../src/wallets/turnkey/sdkLoader.js"); + +const BASE_OPTS = { + organizationId: "org-123", + signWith: SIGN_WITH, + apiPublicKey: "02".repeat(33), + apiPrivateKey: "aa".repeat(32), +}; + +// A domain NOT in knownPaymentTokens — strictDefault must refuse it. +const TEST_DOMAIN: TypedDataDomain = { + name: "TestToken", + version: "1", + chainId: 97, + verifyingContract: getAddress(`0x${"22".repeat(20)}`), +}; + +// Canonical EIP-3009 shape (mirrors EIP3009_CANONICAL_FIELDS) with a +// validity window inside strictDefault's 600s cap. +function eip3009Fixture() { + const nowSec = Math.floor(Date.now() / 1000); + return { + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + message: { + from: SIGN_WITH, + to: getAddress(`0x${"33".repeat(20)}`), + value: 1n, + validAfter: BigInt(nowSec - 10), + validBefore: BigInt(nowSec + 580), + nonce: `0x${"44".repeat(32)}`, + }, + }; +} + +/** Policy that allowlists TEST_DOMAIN on top of strict defaults. */ +function extendedPolicy(): SigningPolicy { + return SigningPolicy.strictDefault().extend({ + domainAllowlist: [ + [97, TEST_DOMAIN.verifyingContract as `0x${string}`], + ] as const, + }); +} + +const TURNKEY_ENV_KEYS = [ + "TURNKEY_API_PUBLIC_KEY", + "TURNKEY_API_PRIVATE_KEY", + "TURNKEY_ORG_ID", + "TURNKEY_SIGN_WITH", + "TURNKEY_API_BASE_URL", +] as const; +const savedEnv: Record = {}; + +beforeEach(() => { + for (const key of TURNKEY_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + setTurnkeySdkImporter(null); + tkMocks.ctorConfigs.length = 0; + tkMocks.apiClientCalls.count = 0; + tkMocks.createAccountCalls.length = 0; + tkMocks.createAccountImpl.fn = null; + tkMocks.recorded.messages.length = 0; + tkMocks.recorded.typedData.length = 0; + tkMocks.recorded.transactions.length = 0; + tkMocks.signFailure.error = null; +}); + +afterEach(() => { + for (const key of TURNKEY_ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + setTurnkeySdkImporter(null); +}); + +describe("construction and capability surface", () => { + it("constructs offline: no module load, no client, no account", () => { + // Declaration-order sensitive: this is the file's first executed test, + // so the vi.mock factories must not have run yet — construction and + // describe() must not touch the optional packages at all. + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const description = provider.describe(); + expect(description.kind).toBe("turnkey"); + expect(tkMocks.factoryRuns.count).toBe(0); + expect(tkMocks.createAccountCalls.length).toBe(0); + expect(tkMocks.apiClientCalls.count).toBe(0); + }); + + it("declares the pure-signer capability set", () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + expect(provider.capabilities()).toEqual( + new Set([ + SIGN_MESSAGE, + SIGN_TRANSACTION, + SIGN_TYPED_DATA, + CALLS_ARBITRARY, + PAYMASTER_SPONSOR, + ]), + ); + expect(TurnkeyWalletProvider.kind).toBe("turnkey"); + }); + + it("address is synchronous and checksummed from lowercase input", () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signWith: SIGN_WITH.toLowerCase(), + }); + expect(provider.address).toBe(SIGN_WITH); + }); + + it("describe() reports the remote key location and never leaks credentials", () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const description = provider.describe(); + expect(description.address).toBe(SIGN_WITH); + expect(description.keyLocation).toContain("remote:turnkey"); + expect(description.keyLocation).toContain(TURNKEY_API_BASE_URL_DEFAULT); + expect(description.exists).toBe(true); + expect(JSON.stringify(description)).not.toContain(BASE_OPTS.apiPrivateKey); + }); + + it.each([ + ["a Turnkey wallet id (UUID)", "3f2504e0-4f89-11d3-9a0c-0305e82c3301"], + ["a private-key id", "pk-12345"], + ["a 39-hex-char near-address", `0x${"ab".repeat(19)}a`], + ["an empty string", ""], + ])("rejects signWith that is %s", (_label, signWith) => { + expect(() => new TurnkeyWalletProvider({ ...BASE_OPTS, signWith })).toThrow( + signWith === "" + ? /'signWith' is required/ + : /must be the wallet account's Ethereum address/, + ); + }); + + it.each(["organizationId", "apiPublicKey", "apiPrivateKey"] as const)( + "requires %s to be non-empty", + (key) => { + expect( + () => new TurnkeyWalletProvider({ ...BASE_OPTS, [key]: "" }), + ).toThrow(new RegExp(`'${key}' is required`)); + }, + ); +}); + +describe("lazy account initialization", () => { + it("concurrent first calls share exactly one createAccount round-trip", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await Promise.all([ + provider.signMessage("one"), + provider.signMessage("two"), + provider.signMessage("three"), + ]); + expect(tkMocks.createAccountCalls.length).toBe(1); + expect(tkMocks.apiClientCalls.count).toBe(1); + expect(tkMocks.ctorConfigs).toEqual([ + { + apiBaseUrl: TURNKEY_API_BASE_URL_DEFAULT, + apiPublicKey: BASE_OPTS.apiPublicKey, + apiPrivateKey: BASE_OPTS.apiPrivateKey, + defaultOrganizationId: BASE_OPTS.organizationId, + }, + ]); + }); + + it("a failed init is not cached — the next call retries and succeeds", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + tkMocks.createAccountImpl.fn = () => + Promise.reject(new Error("transient network failure")); + await expect(provider.signMessage("x")).rejects.toThrow( + /transient network failure/, + ); + tkMocks.createAccountImpl.fn = null; + const result = await provider.signMessage("x"); + expect(result.messageHash).toBe(hashMessage("x")); + expect(tkMocks.createAccountCalls.length).toBe(2); + }); + + it("fails closed with WalletIdentityMismatch when the backend resolves another address", async () => { + const other = privateKeyToAccount(`0x${"d4".repeat(32)}`); + tkMocks.createAccountImpl.fn = async () => other; + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await expect(provider.signMessage("x")).rejects.toThrow( + WalletIdentityMismatch, + ); + }); + + it("honors a custom apiBaseUrl", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + apiBaseUrl: "https://api.turnkey.example", + }); + expect(provider.keyLocation).toContain("https://api.turnkey.example"); + await provider.signMessage("x"); + expect(tkMocks.ctorConfigs[0]?.apiBaseUrl).toBe( + "https://api.turnkey.example", + ); + }); +}); + +describe("signMessage (EIP-191)", () => { + it("round-trips: digest matches hashMessage and the signature recovers", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const result = await provider.signMessage("hello turnkey"); + expect(result.messageHash).toBe(hashMessage("hello turnkey")); + expect(result.signature).toMatch(/^0x[0-9a-f]{130}$/); + expect([27n, 28n]).toContain(result.v); + const reference = await privateKeyToAccount(TEST_PK).signMessage({ + message: "hello turnkey", + }); + expect(result.signature).toBe(reference); + }); +}); + +describe("signTypedData (EIP-712)", () => { + it("policy check runs BEFORE any billable call (strict default refuses unknown domain)", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const { types, message } = eip3009Fixture(); + await expect( + provider.signTypedData(TEST_DOMAIN, types, message), + ).rejects.toThrow(PolicyViolation); + expect(tkMocks.createAccountCalls.length).toBe(0); + expect(tkMocks.recorded.typedData.length).toBe(0); + }); + + it("signs after the policy is extended with the domain", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signingPolicy: extendedPolicy(), + }); + const { types, message } = eip3009Fixture(); + const result = await provider.signTypedData(TEST_DOMAIN, types, message); + expect(result.signature).toMatch(/^0x[0-9a-f]{130}$/); + }); + + it("injects the full EIP712Domain type into the enclave payload (the 0.14.x stripping trap)", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signingPolicy: extendedPolicy(), + }); + const { types, message } = eip3009Fixture(); + await provider.signTypedData(TEST_DOMAIN, types, message); + + const sent = tkMocks.recorded.typedData[0] as { + domain: TypedDataDomain; + types: Record; + primaryType: string; + }; + expect(sent.primaryType).toBe("TransferWithAuthorization"); + expect(sent.types.EIP712Domain).toEqual( + getTypesForEIP712Domain({ domain: TEST_DOMAIN }), + ); + expect(sent.domain).toEqual(TEST_DOMAIN); + }); + + it("replaces a caller-supplied EIP712Domain and signs identically with or without it", async () => { + const { types, message } = eip3009Fixture(); + const withBogusDomainType = { + // Deliberately wrong shape — must be replaced, not trusted. + EIP712Domain: [{ name: "name", type: "string" }], + ...types, + }; + + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signingPolicy: extendedPolicy(), + }); + const withSupplied = await provider.signTypedData( + TEST_DOMAIN, + withBogusDomainType, + message, + ); + const withoutSupplied = await provider.signTypedData( + TEST_DOMAIN, + types, + message, + ); + expect(withSupplied.signature).toBe(withoutSupplied.signature); + + for (const sent of tkMocks.recorded.typedData as { + types: Record; + }[]) { + expect(sent.types.EIP712Domain).toEqual( + getTypesForEIP712Domain({ domain: TEST_DOMAIN }), + ); + } + }); + + it("binds the real domain: recoverTypedDataAddress yields the provider address", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signingPolicy: extendedPolicy(), + }); + const { types, message } = eip3009Fixture(); + const result = await provider.signTypedData(TEST_DOMAIN, types, message); + const recovered = await recoverTypedDataAddress({ + domain: TEST_DOMAIN, + types, + primaryType: "TransferWithAuthorization", + message, + signature: result.signature, + }); + expect(recovered).toBe(provider.address); + expect(result.messageHash).toBe( + hashTypedData({ + domain: TEST_DOMAIN, + types, + primaryType: "TransferWithAuthorization", + message, + } as Parameters[0]), + ); + }); + + it("rejects multi-struct types (primary-type ambiguity) before any billable call", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + signingPolicy: extendedPolicy(), + }); + const { types, message } = eip3009Fixture(); + const multi = { + ...types, + Extra: [{ name: "x", type: "uint256" }], + }; + await expect( + provider.signTypedData(TEST_DOMAIN, multi, message), + ).rejects.toThrow(PolicyViolation); + expect(tkMocks.createAccountCalls.length).toBe(0); + }); +}); + +describe("signTransaction", () => { + const legacyTx = { + chainId: 97, + to: getAddress(`0x${"55".repeat(20)}`), + value: 1n, + nonce: 0, + gas: 21_000n, + gasPrice: 10_000_000_000n, + }; + + it("legacy round-trip: serialized as legacy, hash and r/s/v populated", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const signed = await provider.signTransaction(legacyTx); + const parsed = parseTransaction(signed.rawTransaction); + expect(parsed.type).toBe("legacy"); + expect(parsed.gasPrice).toBe(legacyTx.gasPrice); + expect(signed.hash).toBe(keccak256(signed.rawTransaction)); + // EIP-155 v for chainId 97: 2*97 + 35/36. + expect([229n, 230n]).toContain(signed.v); + expect(signed.r).toMatch(/^0x/); + expect(signed.s).toMatch(/^0x/); + }); + + it("EIP-1559 round-trip: fee fields select the typed transaction", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const signed = await provider.signTransaction({ + chainId: 97, + to: getAddress(`0x${"55".repeat(20)}`), + value: 1n, + nonce: 0, + gas: 21_000n, + maxFeePerGas: 10_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + } as unknown as typeof legacyTx); + const parsed = parseTransaction(signed.rawTransaction); + expect(parsed.type).toBe("eip1559"); + expect(signed.hash).toBe(keccak256(signed.rawTransaction)); + // Typed transactions carry yParity; the provider normalizes v to 27/28. + expect([27n, 28n]).toContain(signed.v); + }); + + it("refuses a chainId mismatch before any billable call when pinned", async () => { + const provider = new TurnkeyWalletProvider({ + ...BASE_OPTS, + expectedChainId: 97, + }); + await expect( + provider.signTransaction({ ...legacyTx, chainId: 56 }), + ).rejects.toThrow(/pinned to chainId=97/); + expect(tkMocks.createAccountCalls.length).toBe(0); + expect(provider.expectedChainId).toBe(97); + }); + + it("unpinned providers sign for any chainId", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const signed = await provider.signTransaction({ + ...legacyTx, + chainId: 56, + }); + expect(parseTransaction(signed.rawTransaction).chainId).toBe(56); + }); +}); + +describe("fromEnv", () => { + const setAllEnv = () => { + process.env.TURNKEY_API_PUBLIC_KEY = BASE_OPTS.apiPublicKey; + process.env.TURNKEY_API_PRIVATE_KEY = BASE_OPTS.apiPrivateKey; + process.env.TURNKEY_ORG_ID = BASE_OPTS.organizationId; + process.env.TURNKEY_SIGN_WITH = SIGN_WITH; + }; + + it("builds a provider from the four required env vars (default base URL)", () => { + setAllEnv(); + const provider = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 }); + expect(provider.address).toBe(SIGN_WITH); + expect(provider.expectedChainId).toBe(97); + expect(provider.keyLocation).toContain(TURNKEY_API_BASE_URL_DEFAULT); + }); + + it("honors TURNKEY_API_BASE_URL and a supplied signingPolicy", () => { + setAllEnv(); + process.env.TURNKEY_API_BASE_URL = "https://api.turnkey.example"; + const policy = extendedPolicy(); + const provider = TurnkeyWalletProvider.fromEnv({ signingPolicy: policy }); + expect(provider.keyLocation).toContain("https://api.turnkey.example"); + expect(provider.signingPolicy).toBe(policy); + }); + + it("names ALL missing env vars in one error", () => { + process.env.TURNKEY_API_PUBLIC_KEY = BASE_OPTS.apiPublicKey; + expect(() => TurnkeyWalletProvider.fromEnv()).toThrow( + /missing required env vars: TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH/, + ); + }); +}); + +describe("vendor error mapping", () => { + it("rewrites rate-limit failures with the 1 RPS hint", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await provider.signMessage("warm up"); + tkMocks.signFailure.error = Object.assign( + new Error("Turnkey error 8: RATE_LIMIT_EXCEEDED"), + { status: 429 }, + ); + await expect(provider.signMessage("x")).rejects.toThrow( + /1 request\/second/, + ); + }); + + it("rewrites quota exhaustion with the billing hint and preserves the cause", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await provider.signMessage("warm up"); + const original = new Error("SIGNING_QUOTA_EXCEEDED for organization"); + tkMocks.signFailure.error = original; + const failure = await provider.signMessage("x").catch((e: unknown) => e); + expect((failure as Error).message).toMatch(/25 billed signatures\/month/); + expect((failure as Error).cause).toBe(original); + }); + + it("rewrites policy denials with the non-root ALLOW-policy hint", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await provider.signMessage("warm up"); + tkMocks.signFailure.error = new Error( + "Turnkey error 7: policy engine rejected the activity (POLICY_REJECTED)", + ); + await expect(provider.signMessage("x")).rejects.toThrow( + /explicit ALLOW policy/, + ); + }); + + it("passes unrecognized errors through untouched", async () => { + const provider = new TurnkeyWalletProvider(BASE_OPTS); + await provider.signMessage("warm up"); + const original = new Error("something else entirely"); + tkMocks.signFailure.error = original; + const failure = await provider.signMessage("x").catch((e: unknown) => e); + expect(failure).toBe(original); + }); +}); + +describe("sdk loader", () => { + it("maps a module-not-found rejection to the pnpm add guidance", async () => { + const notFound = Object.assign( + new Error(`Cannot find package '${TURNKEY_SDK_SERVER_PACKAGE}'`), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + setTurnkeySdkImporter(() => Promise.reject(notFound)); + const failure = await loadTurnkeySdk().catch((e: unknown) => e); + expect((failure as Error).message).toContain( + `pnpm add ${TURNKEY_SDK_SERVER_PACKAGE} ${TURNKEY_VIEM_PACKAGE}`, + ); + expect((failure as Error).cause).toBe(notFound); + }); + + it("passes unrelated importer failures through", async () => { + const boom = new Error("permission denied"); + setTurnkeySdkImporter(() => Promise.reject(boom)); + await expect(loadTurnkeySdk()).rejects.toBe(boom); + }); + + it("does not cache failures — each call retries the importer", async () => { + let calls = 0; + setTurnkeySdkImporter((pkg) => { + calls += 1; + return Promise.reject( + Object.assign(new Error(`Cannot find package '${pkg}'`), { + code: "ERR_MODULE_NOT_FOUND", + }), + ); + }); + await expect(loadTurnkeySdk()).rejects.toThrow(/pnpm add/); + await expect(loadTurnkeySdk()).rejects.toThrow(/pnpm add/); + // Two packages per attempt × two attempts. + expect(calls).toBe(4); + }); + + it("setTurnkeySdkImporter(null) restores the default module source", async () => { + setTurnkeySdkImporter(() => Promise.reject(new Error("armed"))); + await expect(loadTurnkeySdk()).rejects.toThrow(/armed/); + setTurnkeySdkImporter(null); + const provider = new TurnkeyWalletProvider(BASE_OPTS); + const result = await provider.signMessage("recovered"); + expect(result.messageHash).toBe(hashMessage("recovered")); + }); +}); From 69bf112b53cb69846a7ba15fa74dd46606c1a6dc Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:54:05 +0800 Subject: [PATCH 2/7] feat(erc8183): construct turnkey wallets from WALLET_KIND=turnkey Config kind dispatch gains a turnkey branch: TurnkeyWalletProvider.fromEnv pinned to the network's chain id, optional WALLET_ADDRESS anchor checked fail-closed (WalletIdentityMismatch). WALLET_PASSWORD stays EVM-only. --- typescript/src/erc8183/config.ts | 34 ++++++++++-- typescript/tests/erc8183Config.test.ts | 76 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/typescript/src/erc8183/config.ts b/typescript/src/erc8183/config.ts index a225f34..d7e88e2 100644 --- a/typescript/src/erc8183/config.ts +++ b/typescript/src/erc8183/config.ts @@ -34,7 +34,10 @@ * storage returns file:// scheme) * * Global env vars: NETWORK / PRIVATE_KEY / WALLET_PASSWORD / WALLET_ADDRESS - * / WALLET_KIND. + * / WALLET_KIND. With `WALLET_KIND=turnkey` the wallet is built from the + * `TURNKEY_*` env vars (`TURNKEY_API_PUBLIC_KEY`, `TURNKEY_API_PRIVATE_KEY`, + * `TURNKEY_ORG_ID`, `TURNKEY_SIGN_WITH`, optional `TURNKEY_API_BASE_URL`) + * and WALLET_PASSWORD is not required. * * Payment token address is NOT configurable — it is immutable on the * Commerce kernel and fetched at runtime via `ERC8183Client.paymentToken`. @@ -44,11 +47,14 @@ * TypeScript port yet). */ +import { getAddress } from "viem"; import type { NetworkConfig } from "../config.js"; import { getEnv } from "../core/envUtil.js"; import { LocalStorageProvider } from "../storage/localStorageProvider.js"; import type { StorageProvider } from "../storage/storageProvider.js"; +import { WalletIdentityMismatch } from "../wallets/errors.js"; import { EVMWalletProvider } from "../wallets/evmWalletProvider.js"; +import { TurnkeyWalletProvider } from "../wallets/turnkey/provider.js"; import { TWAKProvider, TWAK_CHAIN_FOR_NETWORK, @@ -72,8 +78,10 @@ export interface ERC8183ConfigOpts { walletPassword?: string; /** Select a specific wallet from the keystore directory (by address). */ walletAddress?: string; - /** Select a non-EVM provider (out of scope; `"evm"` / `""` are the only - * accepted values — anything else throws). */ + /** Select the wallet backend: `"evm"` / `""` (keystore), `"twak"` + * (self-broadcasting CLI) or `"turnkey"` (remote enclave signer, built + * from the `TURNKEY_*` env vars) — anything else throws. Advisory + * metadata only when `walletProvider` is supplied directly. */ walletKind?: string; /** Off-chain storage for deliverables. */ storage?: StorageProvider | null; @@ -139,6 +147,22 @@ export class ERC8183Config { chain, ...(this.walletAddress ? { expectedAddress: this.walletAddress } : {}), }); + } else if (kind === "turnkey" && !walletProvider) { + // Remote enclave signer, credentials from the TURNKEY_* env vars. + // Pin the network's chain id so a mismatching transaction fails + // closed before Turnkey's billable API call. + walletProvider = TurnkeyWalletProvider.fromEnv({ + expectedChainId: this.effectiveChainId, + }); + if ( + this.walletAddress && + getAddress(this.walletAddress) !== walletProvider.address + ) { + throw new WalletIdentityMismatch({ + expected: getAddress(this.walletAddress), + actual: walletProvider.address, + }); + } } else if (kind && kind !== "evm" && !walletProvider) { throw new Error(`Unknown wallet kind: ${this.walletKind}`); } @@ -257,8 +281,8 @@ export class ERC8183Config { const walletKind = getEnv("WALLET_KIND") ?? ""; // The WALLET_PASSWORD requirement is an EVM-kind concern: a non-EVM - // kind (twak) owns its custody end-to-end, so no password is needed - // here — the constructor dispatches it to TWAKProvider. + // kind (twak, turnkey) owns its custody end-to-end, so no password is + // needed here — the constructor dispatches to the matching provider. const kind = walletKind.trim().toLowerCase(); if (kind === "" || kind === "evm") { if (!walletPassword) { diff --git a/typescript/tests/erc8183Config.test.ts b/typescript/tests/erc8183Config.test.ts index 139a4df..b7a4539 100644 --- a/typescript/tests/erc8183Config.test.ts +++ b/typescript/tests/erc8183Config.test.ts @@ -46,6 +46,10 @@ vi.mock("node:os", async (importOriginal) => { }); const { ERC8183Config } = await import("../src/erc8183/config.js"); +const { TurnkeyWalletProvider } = await import( + "../src/wallets/turnkey/provider.js" +); +const { WalletIdentityMismatch } = await import("../src/wallets/errors.js"); const VALID_PK = `0x${"cd".repeat(32)}`; const VALID_PASSWORD = "test-password"; @@ -85,6 +89,11 @@ const ENV_KEYS = [ "ERC8183_SERVICE_PRICE", "ERC8183_AGENT_URL", "STORAGE_LOCAL_PATH", + "TURNKEY_API_PUBLIC_KEY", + "TURNKEY_API_PRIVATE_KEY", + "TURNKEY_ORG_ID", + "TURNKEY_SIGN_WITH", + "TURNKEY_API_BASE_URL", ] as const; let saved: Record; @@ -258,6 +267,73 @@ describe("ERC8183Config: wallet_kind", () => { }); }); +describe("ERC8183Config: wallet_kind=turnkey", () => { + const TURNKEY_SIGN_WITH = `0x${"7a".repeat(20)}`; + + const setTurnkeyEnv = () => { + process.env.TURNKEY_API_PUBLIC_KEY = "02".repeat(33); + process.env.TURNKEY_API_PRIVATE_KEY = "aa".repeat(32); + process.env.TURNKEY_ORG_ID = "org-123"; + process.env.TURNKEY_SIGN_WITH = TURNKEY_SIGN_WITH; + }; + + it("dispatches to TurnkeyWalletProvider pinned to the network's chain id", () => { + setTurnkeyEnv(); + const config = new ERC8183Config({ walletKind: "turnkey" }); + expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider); + const provider = config.walletProvider as InstanceType< + typeof TurnkeyWalletProvider + >; + // Default network is bsc-testnet → fail-closed pin to 97. + expect(provider.expectedChainId).toBe(97); + expect(provider.address.toLowerCase()).toBe(TURNKEY_SIGN_WITH); + }); + + it("surfaces the missing TURNKEY_* env vars in one error", () => { + process.env.TURNKEY_API_PUBLIC_KEY = "02".repeat(33); + expect(() => new ERC8183Config({ walletKind: "turnkey" })).toThrow( + /missing required env vars: TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH/, + ); + }); + + it("fails closed on a WALLET_ADDRESS drift (WalletIdentityMismatch)", () => { + setTurnkeyEnv(); + expect( + () => + new ERC8183Config({ + walletKind: "turnkey", + walletAddress: `0x${"9b".repeat(20)}`, + }), + ).toThrow(WalletIdentityMismatch); + }); + + it("accepts a matching WALLET_ADDRESS anchor (case-insensitive)", () => { + setTurnkeyEnv(); + const config = new ERC8183Config({ + walletKind: "turnkey", + walletAddress: TURNKEY_SIGN_WITH.toUpperCase().replace("0X", "0x"), + }); + expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider); + }); + + it("treats wallet_kind as advisory when a wallet_provider is supplied", () => { + const stub = new StubWallet(`0x${"11".repeat(20)}`); + const config = new ERC8183Config({ + walletKind: "turnkey", + walletProvider: stub, + }); + expect(config.walletProvider).toBe(stub); + }); + + it("fromEnv builds a turnkey wallet without requiring WALLET_PASSWORD", () => { + setTurnkeyEnv(); + process.env.WALLET_KIND = "turnkey"; + const config = ERC8183Config.fromEnv(new FakeStorage()); + expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider); + expect(config.walletKind).toBe("turnkey"); + }); +}); + describe("ERC8183Config.fromEnv", () => { it("resolves rpc_url and ERC8183_*_ADDRESS overrides from env", () => { process.env.RPC_URL = "https://rpc.example.com"; From abbbbececf1e72462ebb2f0327e8636cad425e40 Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:54:05 +0800 Subject: [PATCH 3/7] test: pin turnkey public API + type-compat against real vendor types publicApi asserts the Tier-1/Tier-2 turnkey surface; turnkeyTypeCompat pins the structural mirrors against @turnkey/sdk-server@8.1.0 and @turnkey/viem@0.14.34 at typecheck time. --- typescript/tests/publicApi.test.ts | 14 ++++++ typescript/tests/turnkeyTypeCompat.test.ts | 51 ++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 typescript/tests/turnkeyTypeCompat.test.ts diff --git a/typescript/tests/publicApi.test.ts b/typescript/tests/publicApi.test.ts index 02b23c0..0f05ff4 100644 --- a/typescript/tests/publicApi.test.ts +++ b/typescript/tests/publicApi.test.ts @@ -66,6 +66,8 @@ describe("Tier 1 public API (src/index.ts)", () => { expect(typeof Tier1.EVMWalletProvider).toBe("function"); expect(typeof Tier1.AltanaWalletProvider).toBe("function"); expect(Tier1.AltanaWalletProvider.kind).toBe("altana"); + expect(typeof Tier1.TurnkeyWalletProvider).toBe("function"); + expect(Tier1.TurnkeyWalletProvider.kind).toBe("turnkey"); }); it("exports ERC-8183 essentials", () => { @@ -201,6 +203,18 @@ describe("Tier 2 subpath: ./wallets", () => { expect(typeof Wallets.ALTANA_NONCE_RETRY_TRIES).toBe("number"); expect(typeof Wallets.ALTANA_NONCE_RETRY_DELAY_MS).toBe("number"); }); + + it("exports the turnkey surface (provider, loader seam, constants)", () => { + expect(typeof Wallets.TurnkeyWalletProvider).toBe("function"); + expect(Wallets.TurnkeyWalletProvider.kind).toBe("turnkey"); + expect(Wallets.TURNKEY_SDK_SERVER_PACKAGE).toBe("@turnkey/sdk-server"); + expect(Wallets.TURNKEY_VIEM_PACKAGE).toBe("@turnkey/viem"); + expect(typeof Wallets.setTurnkeySdkImporter).toBe("function"); + expect(typeof Wallets.TURNKEY_API_BASE_URL_DEFAULT).toBe("string"); + expect(Wallets.TURNKEY_API_BASE_URL_DEFAULT.startsWith("https://")).toBe( + true, + ); + }); }); describe("Tier 2 subpath: ./signing", () => { diff --git a/typescript/tests/turnkeyTypeCompat.test.ts b/typescript/tests/turnkeyTypeCompat.test.ts new file mode 100644 index 0000000..06bc941 --- /dev/null +++ b/typescript/tests/turnkeyTypeCompat.test.ts @@ -0,0 +1,51 @@ +/** + * Compile-time drift detection between `src/wallets/turnkey/types.ts` (the + * SDK-shipped structural mirrors) and the real `@turnkey/sdk-server` / + * `@turnkey/viem` types (devDependencies, imported type-only so the + * optional peers never enter the runtime graph). + * + * The failure surface is `pnpm typecheck`, not the test runner: every + * function below is an assignability assertion the compiler must accept. + * Module/class shapes are pinned real → mirror (our mirrors are a + * deliberate subset of the vendor surface); the constructor config is + * pinned mirror → real (we build the config and feed it to the vendor). + */ + +import type * as TurnkeySdkServer from "@turnkey/sdk-server"; +import type * as TurnkeyViem from "@turnkey/viem"; +import { describe, expect, it } from "vitest"; +import type { + TurnkeyClientConfig, + TurnkeySdkServerModule, + TurnkeyViemModule, +} from "../src/wallets/turnkey/types.js"; + +// ── Module shapes: the real packages must satisfy our internal subset ───── + +const sdkServerModuleToMirror = ( + v: typeof TurnkeySdkServer, +): TurnkeySdkServerModule => v; + +const viemModuleToMirror = (v: typeof TurnkeyViem): TurnkeyViemModule => v; + +// ── Constructor config: our config values feed the real constructor ─────── + +const configToReal = ( + v: TurnkeyClientConfig, +): ConstructorParameters[0] => v; + +describe("turnkey type mirrors", () => { + it("stay assignable against @turnkey/sdk-server and @turnkey/viem (compile-time contract)", () => { + // The assignability functions above ARE the assertions; tsc rejects + // this file the moment the mirrors drift. Referencing them here keeps + // them live under lint and gives vitest a runtime anchor. + const witnesses = [ + sdkServerModuleToMirror, + viemModuleToMirror, + configToReal, + ]; + for (const witness of witnesses) { + expect(typeof witness).toBe("function"); + } + }); +}); From 37c2333ff58cbf83a13745e4ecb837dbdb4a21c7 Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:14:14 +0800 Subject: [PATCH 4/7] feat(python): TurnkeyWalletProvider with self-built stamper and activity client Turnkey ships no Python SDK, so this adds the thin slice the provider needs: P-256 X-Stamp request stamping (cryptography, new optional extra bnbagent[turnkey]), a minimal SIGN_RAW_PAYLOAD_V2/SIGN_TRANSACTION_V2 activity client over requests, and a pure-signer provider (191 blind digest, 712 full-document payload with EIP712Domain always included, legacy+1559 unsigned RLP). Registered as factory kind 'turnkey'; conformance matrix extended; fake in-process enclave (eth_account) keeps CI offline. --- python/bnbagent/core/config.py | 20 + python/bnbagent/wallets/__init__.py | 2 + python/bnbagent/wallets/factory.py | 15 +- python/bnbagent/wallets/turnkey/__init__.py | 17 + python/bnbagent/wallets/turnkey/client.py | 210 +++++++++ python/bnbagent/wallets/turnkey/provider.py | 472 ++++++++++++++++++++ python/bnbagent/wallets/turnkey/stamper.py | 99 ++++ python/pyproject.toml | 5 + python/tests/test_erc8183_config.py | 66 ++- python/tests/test_turnkey_provider.py | 454 +++++++++++++++++++ python/tests/test_wallet_conformance.py | 19 + python/tests/test_wallet_factory.py | 15 +- python/tests/turnkey_fake.py | 164 +++++++ python/uv.lock | 184 +++++++- 14 files changed, 1726 insertions(+), 16 deletions(-) create mode 100644 python/bnbagent/wallets/turnkey/__init__.py create mode 100644 python/bnbagent/wallets/turnkey/client.py create mode 100644 python/bnbagent/wallets/turnkey/provider.py create mode 100644 python/bnbagent/wallets/turnkey/stamper.py create mode 100644 python/tests/test_turnkey_provider.py create mode 100644 python/tests/turnkey_fake.py diff --git a/python/bnbagent/core/config.py b/python/bnbagent/core/config.py index 3f5ea4f..5213120 100644 --- a/python/bnbagent/core/config.py +++ b/python/bnbagent/core/config.py @@ -103,6 +103,26 @@ def __post_init__(self): ): from ..wallets import create_wallet_provider + if self.wallet_kind.strip().lower() == "turnkey": + # Remote enclave signer, credentials from the TURNKEY_* env + # vars. Pin the network's chain id so a mismatching + # transaction fails closed before Turnkey's billable API + # call (mirrors the TypeScript ERC8183Config branch). + from eth_utils import to_checksum_address + + from ..wallets.errors import WalletIdentityMismatch + from ..wallets.turnkey import TurnkeyWalletProvider + + provider = TurnkeyWalletProvider.from_env( + expected_chain_id=self.effective_network.chain_id + ) + if self.wallet_address: + expected = to_checksum_address(self.wallet_address) + if expected != provider.address: + raise WalletIdentityMismatch(expected=expected, actual=provider.address) + self.wallet_provider = provider + return + kwargs: dict[str, str] = {} if self.wallet_kind.strip().lower() == "twak": # Pin twak to the config's network (chain identity must not diff --git a/python/bnbagent/wallets/__init__.py b/python/bnbagent/wallets/__init__.py index e917607..c02c4ae 100644 --- a/python/bnbagent/wallets/__init__.py +++ b/python/bnbagent/wallets/__init__.py @@ -14,6 +14,7 @@ from .intents import ExecutionContext, Intent, IntentExecutor from .mpc_wallet_provider import MPCWalletProvider from .protocols import MessageSigner, TypedDataSigner +from .turnkey import TurnkeyWalletProvider from .twak_custody import materialize_twak_home from .twak_provider import TWAK_CHAIN_FOR_NETWORK, TWAKProvider from .wallet_provider import WalletProvider @@ -23,6 +24,7 @@ "EVMWalletProvider", "MPCWalletProvider", "TWAKProvider", + "TurnkeyWalletProvider", "UnsupportedWalletOperation", "WalletIdentityMismatch", "materialize_twak_home", diff --git a/python/bnbagent/wallets/factory.py b/python/bnbagent/wallets/factory.py index 61d2101..ec14d19 100644 --- a/python/bnbagent/wallets/factory.py +++ b/python/bnbagent/wallets/factory.py @@ -20,18 +20,21 @@ from .wallet_provider import WalletProvider #: Wallet kinds the factory can construct. Mirrors each provider's ``kind``. -SUPPORTED_WALLET_KINDS: tuple[str, ...] = ("evm", "twak", "mpc") +SUPPORTED_WALLET_KINDS: tuple[str, ...] = ("evm", "twak", "mpc", "turnkey") def create_wallet_provider(kind: str, **kwargs: Any) -> WalletProvider: """Construct a :class:`WalletProvider` for ``kind``. Args: - kind: Provider identifier (case-insensitive): ``"evm"``, ``"twak"`` - or ``"mpc"``. + kind: Provider identifier (case-insensitive): ``"evm"``, ``"twak"``, + ``"mpc"`` or ``"turnkey"``. **kwargs: Forwarded verbatim to the provider constructor. Required arguments differ per kind — e.g. ``EVMWalletProvider`` needs - ``password=...``; ``TWAKProvider`` accepts ``chain=...``. + ``password=...``; ``TWAKProvider`` accepts ``chain=...``; + ``TurnkeyWalletProvider`` needs the ``organization_id`` / + ``sign_with`` / ``api_public_key`` / ``api_private_key`` + credentials (or use ``TurnkeyWalletProvider.from_env()``). Returns: The constructed provider. @@ -55,6 +58,10 @@ def create_wallet_provider(kind: str, **kwargs: Any) -> WalletProvider: from .mpc_wallet_provider import MPCWalletProvider return MPCWalletProvider(**kwargs) + if normalized == "turnkey": + from .turnkey import TurnkeyWalletProvider + + return TurnkeyWalletProvider(**kwargs) raise ValueError( f"Unknown wallet kind {kind!r}. " diff --git a/python/bnbagent/wallets/turnkey/__init__.py b/python/bnbagent/wallets/turnkey/__init__.py new file mode 100644 index 0000000..19d5889 --- /dev/null +++ b/python/bnbagent/wallets/turnkey/__init__.py @@ -0,0 +1,17 @@ +"""Turnkey wallet provider — remote signing, keys in AWS Nitro Enclaves. + +The internal stamper/client plumbing stays out of the package barrel; the +public surface is the provider plus the API-host constant. +""" + +from __future__ import annotations + +from .client import TURNKEY_API_BASE_URL_DEFAULT, TurnkeyApiError, TurnkeyClient +from .provider import TurnkeyWalletProvider + +__all__ = [ + "TURNKEY_API_BASE_URL_DEFAULT", + "TurnkeyApiError", + "TurnkeyClient", + "TurnkeyWalletProvider", +] diff --git a/python/bnbagent/wallets/turnkey/client.py b/python/bnbagent/wallets/turnkey/client.py new file mode 100644 index 0000000..fe32c79 --- /dev/null +++ b/python/bnbagent/wallets/turnkey/client.py @@ -0,0 +1,210 @@ +"""Minimal Turnkey activity client — the two signing endpoints only. + +Turnkey has no official Python SDK (only a stamper utility), so this module +implements the thin slice the wallet provider needs, mirroring the wire +behavior of ``@turnkey/http``: + +- ``POST /public/v1/submit/sign_raw_payload`` + (``ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2``) +- ``POST /public/v1/submit/sign_transaction`` + (``ACTIVITY_TYPE_SIGN_TRANSACTION_V2``) +- ``POST /public/v1/query/get_activity`` (short poll for the rare + still-pending activity; sign activities normally execute synchronously) + +Every request body is stamped (see :mod:`.stamper`) over the exact bytes +sent. Free-tier reality baked into callers: every *successful* signature is +billed (25/month at 1 request/second), so callers gate everything they can +BEFORE invoking this client. +""" + +from __future__ import annotations + +import json +import time +from typing import Any + +import requests + +from .stamper import ApiKeyStamper + +TURNKEY_API_BASE_URL_DEFAULT = "https://api.turnkey.com" + +_ACTIVITY_TERMINAL_OK = "ACTIVITY_STATUS_COMPLETED" +_ACTIVITY_IN_FLIGHT = ("ACTIVITY_STATUS_CREATED", "ACTIVITY_STATUS_PENDING") +_POLL_ATTEMPTS = 10 +_POLL_INTERVAL_S = 0.5 + + +class TurnkeyApiError(RuntimeError): + """A Turnkey API request failed (transport, HTTP or activity level).""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + activity_status: str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.activity_status = activity_status + + +class TurnkeyClient: + """Stamped HTTP client for the two Turnkey signing activities.""" + + def __init__( + self, + *, + api_base_url: str, + api_public_key: str, + api_private_key: str, + organization_id: str, + session: requests.Session | None = None, + timeout: float = 30.0, + ) -> None: + self._base_url = api_base_url.rstrip("/") + self._organization_id = organization_id + self._stamper = ApiKeyStamper( + api_public_key=api_public_key, api_private_key=api_private_key + ) + self._session = session or requests.Session() + self._timeout = timeout + + # ── Activities ──────────────────────────────────────────────────── + + def sign_raw_payload( + self, + *, + sign_with: str, + payload: str, + encoding: str, + hash_function: str, + ) -> dict[str, str]: + """Run ``SIGN_RAW_PAYLOAD_V2``; returns ``{"r", "s", "v"}``. + + ``r``/``s`` are 32-byte hex strings and ``v`` is the recovery id as + hex (``"00"``/``"01"``) — all without a ``0x`` prefix, exactly as + the API returns them. Callers normalize. + """ + activity = self._submit( + "/public/v1/submit/sign_raw_payload", + { + "type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", + "organizationId": self._organization_id, + "parameters": { + "signWith": sign_with, + "payload": payload, + "encoding": encoding, + "hashFunction": hash_function, + }, + "timestampMs": _timestamp_ms(), + }, + ) + result = (activity.get("result") or {}).get("signRawPayloadResult") + if not result: + raise TurnkeyApiError( + "Turnkey activity completed without a signRawPayloadResult", + activity_status=activity.get("status"), + ) + return {"r": result["r"], "s": result["s"], "v": result["v"]} + + def sign_transaction(self, *, sign_with: str, unsigned_transaction: str) -> str: + """Run ``SIGN_TRANSACTION_V2``; returns the signed RLP hex (no ``0x``). + + ``unsigned_transaction`` is the serialized unsigned transaction hex + without a ``0x`` prefix (legacy or EIP-1559 — the enclave parses + either). + """ + activity = self._submit( + "/public/v1/submit/sign_transaction", + { + "type": "ACTIVITY_TYPE_SIGN_TRANSACTION_V2", + "organizationId": self._organization_id, + "parameters": { + "signWith": sign_with, + "type": "TRANSACTION_TYPE_ETHEREUM", + "unsignedTransaction": unsigned_transaction, + }, + "timestampMs": _timestamp_ms(), + }, + ) + result = (activity.get("result") or {}).get("signTransactionResult") or {} + signed = result.get("signedTransaction") + if not signed: + raise TurnkeyApiError( + "Turnkey activity completed without a signedTransaction", + activity_status=activity.get("status"), + ) + return str(signed) + + # ── Plumbing ────────────────────────────────────────────────────── + + def _submit(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + activity = self._post(path, body) + for _ in range(_POLL_ATTEMPTS): + status = activity.get("status") + if status == _ACTIVITY_TERMINAL_OK: + return activity + if status not in _ACTIVITY_IN_FLIGHT: + raise TurnkeyApiError( + f"Turnkey activity ended in {status}: " + f"{activity.get('failure') or activity.get('type', '')}".strip(), + activity_status=status, + ) + time.sleep(_POLL_INTERVAL_S) + activity = self._post( + "/public/v1/query/get_activity", + { + "organizationId": self._organization_id, + "activityId": activity.get("id", ""), + }, + ) + raise TurnkeyApiError( + "Turnkey activity still pending after " + f"{_POLL_ATTEMPTS * _POLL_INTERVAL_S:.0f}s of polling", + activity_status=activity.get("status"), + ) + + def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + # The stamp signs the exact bytes on the wire, so serialize once and + # send that same string. + payload = json.dumps(body, separators=(",", ":")) + header_name, header_value = self._stamper.stamp(payload) + response = self._session.post( + f"{self._base_url}{path}", + data=payload.encode("utf-8"), + headers={ + "Content-Type": "application/json", + header_name: header_value, + }, + timeout=self._timeout, + ) + if response.status_code >= 400: + raise TurnkeyApiError( + f"Turnkey API {path} failed with HTTP {response.status_code}: " + f"{_error_message(response)}", + status_code=response.status_code, + ) + try: + parsed = response.json() + except ValueError as exc: + raise TurnkeyApiError(f"Turnkey API {path} returned non-JSON body") from exc + activity = parsed.get("activity") + if not isinstance(activity, dict): + raise TurnkeyApiError(f"Turnkey API {path} response has no activity envelope") + return activity + + +def _error_message(response: requests.Response) -> str: + try: + body = response.json() + except ValueError: + return response.text[:500] + if isinstance(body, dict): + return str(body.get("message") or body)[:500] + return str(body)[:500] + + +def _timestamp_ms() -> str: + return str(int(time.time() * 1000)) diff --git a/python/bnbagent/wallets/turnkey/provider.py b/python/bnbagent/wallets/turnkey/provider.py new file mode 100644 index 0000000..95a28c3 --- /dev/null +++ b/python/bnbagent/wallets/turnkey/provider.py @@ -0,0 +1,472 @@ +"""Turnkey Wallet Provider — remote signing, keys in AWS Nitro Enclaves. + +A pure signer over Turnkey's hosted key-management API +(https://docs.turnkey.com): the private key is generated and held inside +Turnkey's enclave and never leaves it; every sign call is an authenticated +HTTPS round-trip stamped by a locally held P-256 API key pair. No key +material ever exists on this machine. + +Operational constraints that shaped this implementation (mirrors the +TypeScript ``TurnkeyWalletProvider``): + +- **Every successful signature is billed** (free tier: 25/month at + 1 request/second; pay-as-you-go $0.10/signature). All client-side guards + (SigningPolicy, chain-id pinning, input validation) run BEFORE the API + call so a refusal never costs quota. +- **Root API keys bypass ALL Turnkey server-side policies** (root quorum). + Production deployments must use a non-root API user plus an explicit + ALLOW policy; this cannot be detected client-side. +- **Broadcast is not included** (managed broadcast is a paid feature). The + provider only signs; the default ``LocalExecutor`` broadcasts over the + SDK's own RPC, and the MegaFuel paymaster path works unchanged. +- **EIP-712 payloads are built here, domain included.** The full typed-data + JSON goes to the enclave (``PAYLOAD_ENCODING_EIP712``), where server-side + policies can filter on domain / primary type / message. Unlike the + TypeScript path through ``@turnkey/viem`` (which silently serializes a + missing ``EIP712Domain`` type as ``{}``), this module constructs the + payload itself and always includes the full ``EIP712Domain`` entry — the + same trap, immunized by construction and pinned by tests. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +import rlp +from eth_account.messages import defunct_hash_message, encode_typed_data +from eth_utils import keccak, to_checksum_address +from hexbytes import HexBytes + +from ...signing import SigningPolicy, infer_primary_type +from ...signing import check as _policy_check +from ..capabilities import CALLS_ARBITRARY, PAYMASTER_SPONSOR +from ..wallet_provider import WalletProvider +from .client import TURNKEY_API_BASE_URL_DEFAULT, TurnkeyClient + +# JSON numbers above 2**53-1 lose precision in double-based parsers, so big +# uint256 values are serialized as decimal strings (the enclave accepts +# both; ``@turnkey/viem`` does the same for JS bigints). +_JSON_SAFE_INT_MAX = 2**53 - 1 + +# EIP-712 canonical domain field order; only fields present in the domain +# are included (mirrors viem's ``getTypesForEIP712Domain``). +_EIP712_DOMAIN_FIELDS: tuple[tuple[str, str], ...] = ( + ("name", "string"), + ("version", "string"), + ("chainId", "uint256"), + ("verifyingContract", "address"), + ("salt", "bytes32"), +) + + +def _map_vendor_error(op: str, error: Exception) -> Exception: + """Rewrite recognizable Turnkey API failures into actionable errors. + + Detection is string/shape-based (same heuristics as the TypeScript + provider); unrecognized errors pass through untouched. + """ + message = str(error) + lowered = message.lower() + status = getattr(error, "status_code", None) + if "quota" in lowered: + return RuntimeError( + f"Turnkey signature quota exhausted during {op} (free tier: 25 " + "billed signatures/month; pay-as-you-go $0.10/signature) — check " + "the Turnkey billing dashboard." + ) + if status == 429 or "rate limit" in lowered or "rate_limit" in lowered: + return RuntimeError( + f"Turnkey rate limit hit during {op} (free tier allows 1 " + "request/second) — pace calls or upgrade the plan." + ) + if "policy" in lowered and ("denied" in lowered or "reject" in lowered): + return RuntimeError( + f"Turnkey server-side policy denied {op} — verify the API user " + "has an explicit ALLOW policy covering this operation (non-root " + "users are default-deny)." + ) + return error + + +def _json_safe(value: Any) -> Any: + """Make a typed-data value JSON-serializable without changing its hash. + + Ints beyond the double-precision range become decimal strings; bytes + become 0x-hex strings (EIP-712 encoders treat all three spellings + identically). + """ + if isinstance(value, bool): + return value + if isinstance(value, int): + return value if -_JSON_SAFE_INT_MAX <= value <= _JSON_SAFE_INT_MAX else str(value) + if isinstance(value, (bytes, bytearray)): + return "0x" + bytes(value).hex() + if isinstance(value, dict): + return {key: _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + return value + + +def _eip712_domain_types(domain: dict[str, Any]) -> list[dict[str, str]]: + """Field descriptors for the ``EIP712Domain`` struct (present keys only).""" + return [ + {"name": name, "type": type_} + for name, type_ in _EIP712_DOMAIN_FIELDS + if domain.get(name) is not None + ] + + +# ── Unsigned-transaction serialization (legacy + EIP-1559) ──────────────── +# +# Hand-rolled over the ``rlp`` package (a stable eth-account transitive +# dependency) instead of eth_account's private ``_utils`` modules, whose +# paths shift between releases. Only the two shapes the SDK produces are +# supported; correctness is pinned by round-trip unit tests and the live +# E2E (the enclave parses these bytes). + + +def _int_field(tx: dict[str, Any], key: str, default: int | None = None) -> int: + value = tx.get(key, default) + if value is None: + raise ValueError(f"transaction is missing required field {key!r}") + return int(value) + + +def _address_bytes(value: Any) -> bytes: + if value in (None, "", "0x"): + return b"" # contract creation + return bytes.fromhex(str(value)[2:] if str(value).startswith("0x") else str(value)) + + +def _data_bytes(value: Any) -> bytes: + if value in (None, "", "0x"): + return b"" + text = str(value) + return bytes.fromhex(text[2:] if text.startswith("0x") else text) + + +def _serialize_unsigned_transaction(tx: dict[str, Any]) -> bytes: + """Serialize a legacy or EIP-1559 transaction dict to unsigned bytes. + + Type selection mirrors viem: presence of ``maxFeePerGas`` / + ``maxPriorityFeePerGas`` selects EIP-1559, otherwise legacy + (``gasPrice``). Access lists and blob fields are not supported. + """ + chain_id = _int_field(tx, "chainId") + nonce = _int_field(tx, "nonce") + gas = _int_field(tx, "gas") + to = _address_bytes(tx.get("to")) + value = _int_field(tx, "value", 0) + data = _data_bytes(tx.get("data")) + if tx.get("accessList"): + raise ValueError("the turnkey provider does not support accessList transactions") + + if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx: + max_priority = _int_field(tx, "maxPriorityFeePerGas") + max_fee = _int_field(tx, "maxFeePerGas") + return b"\x02" + rlp.encode( + [chain_id, nonce, max_priority, max_fee, gas, to, value, data, []] + ) + + gas_price = _int_field(tx, "gasPrice") + # Unsigned EIP-155 payload: the chain id takes the signature slots. + return rlp.encode([nonce, gas_price, gas, to, value, data, chain_id, 0, 0]) + + +def _decode_int(value: bytes) -> int: + return int.from_bytes(value, "big") if value else 0 + + +def _parse_signed_transaction(raw: bytes) -> dict[str, int]: + """Extract ``r``/``s``/``v`` from a signed legacy or EIP-1559 RLP. + + ``v`` follows eth-account semantics: the EIP-155 value for legacy + transactions, the y-parity bit (0/1) for typed transactions. + """ + if raw[:1] == b"\x02": + fields = rlp.decode(raw[1:]) + if len(fields) != 12: + raise ValueError(f"unexpected EIP-1559 transaction shape ({len(fields)} fields)") + return { + "v": _decode_int(fields[9]), + "r": _decode_int(fields[10]), + "s": _decode_int(fields[11]), + } + fields = rlp.decode(raw) + if len(fields) != 9: + raise ValueError(f"unexpected legacy transaction shape ({len(fields)} fields)") + return { + "v": _decode_int(fields[6]), + "r": _decode_int(fields[7]), + "s": _decode_int(fields[8]), + } + + +class TurnkeyWalletProvider(WalletProvider): + """Wallet provider backed by Turnkey's remote enclave signing service. + + A pure signer: implements the three ``sign_*`` methods (capabilities + derive automatically) and inherits the default ``LocalExecutor`` path, + so ERC-8004 / ERC-8183 writes, x402 payments (via ``X402Signer``) and + MegaFuel sponsorship all work without Turnkey-specific wiring. + + Construction is cheap and offline — the HTTP client (and its optional + ``cryptography`` dependency) is built lazily on the first sign call. + + Args: + organization_id: Turnkey organization id (dashboard → settings). + sign_with: The wallet account to sign with — MUST be the account's + Ethereum address (``0x`` + 40 hex chars), not a Turnkey wallet + id or private-key id. + api_public_key: Compressed P-256 API public key (dashboard → API keys). + api_private_key: P-256 API private key hex. A client credential — + never leaves this process. + api_base_url: API host override (default ``https://api.turnkey.com``). + expected_chain_id: When set, :meth:`sign_transaction` refuses any + transaction whose ``chainId`` differs — fail-closed BEFORE the + billable API call. + signing_policy: Policy applied to every :meth:`sign_typed_data` + call, BEFORE the billable API call. Defaults to + :meth:`SigningPolicy.strict_default`. This client-side gate is + the first of two layers — Turnkey's server-side policy engine is + the second (and is bypassed entirely for root API users). + client: Test seam — a pre-built object with the + :class:`~bnbagent.wallets.turnkey.client.TurnkeyClient` surface. + """ + + kind = "turnkey" + # Arbitrary mechanical contract calls via LocalExecutor; sponsored + # broadcast via the MegaFuel paymaster (gasPrice=0 legacy signing + # verified against the enclave, probe 2026-07-27). sign.* derive + # automatically since all three sign methods below are overridden. + _extra_capabilities = frozenset({CALLS_ARBITRARY, PAYMASTER_SPONSOR}) + + def __init__( + self, + *, + organization_id: str, + sign_with: str, + api_public_key: str, + api_private_key: str, + api_base_url: str | None = None, + expected_chain_id: int | None = None, + signing_policy: SigningPolicy | None = None, + client: TurnkeyClient | None = None, + ) -> None: + for name, value in ( + ("organization_id", organization_id), + ("sign_with", sign_with), + ("api_public_key", api_public_key), + ("api_private_key", api_private_key), + ): + if not value: + raise ValueError(f"TurnkeyWalletProvider: {name!r} is required") + if len(sign_with) != 42 or not sign_with.startswith("0x"): + raise ValueError( + "TURNKEY_SIGN_WITH must be the wallet account's Ethereum " + "address (0x + 40 hex chars), not a Turnkey wallet id or " + "private-key id — copy the address from the Turnkey dashboard " + f"wallet-account view. Got: {sign_with!r}" + ) + try: + self._address = to_checksum_address(sign_with) + except ValueError as exc: + raise ValueError( + f"TURNKEY_SIGN_WITH is not a valid Ethereum address: {sign_with!r}" + ) from exc + self._organization_id = organization_id + self._api_public_key = api_public_key + self._api_private_key = api_private_key + self._api_base_url = api_base_url or TURNKEY_API_BASE_URL_DEFAULT + self._expected_chain_id = expected_chain_id + self._signing_policy = signing_policy or SigningPolicy.strict_default() + self._client = client + + @classmethod + def from_env( + cls, + *, + expected_chain_id: int | None = None, + signing_policy: SigningPolicy | None = None, + ) -> TurnkeyWalletProvider: + """Build a provider from the ``TURNKEY_*`` environment variables. + + Required: ``TURNKEY_API_PUBLIC_KEY``, ``TURNKEY_API_PRIVATE_KEY``, + ``TURNKEY_ORG_ID``, ``TURNKEY_SIGN_WITH``. Optional: + ``TURNKEY_API_BASE_URL``. + """ + values = { + key: (os.environ.get(key) or "").strip() + for key in ( + "TURNKEY_API_PUBLIC_KEY", + "TURNKEY_API_PRIVATE_KEY", + "TURNKEY_ORG_ID", + "TURNKEY_SIGN_WITH", + ) + } + missing = [key for key, value in values.items() if not value] + if missing: + raise ValueError( + "TurnkeyWalletProvider.from_env: missing required env vars: " + f"{', '.join(missing)}. The values come from the Turnkey " + "dashboard (API keys, organization settings, wallet account " + "address)." + ) + return cls( + api_public_key=values["TURNKEY_API_PUBLIC_KEY"], + api_private_key=values["TURNKEY_API_PRIVATE_KEY"], + organization_id=values["TURNKEY_ORG_ID"], + sign_with=values["TURNKEY_SIGN_WITH"], + api_base_url=os.environ.get("TURNKEY_API_BASE_URL") or None, + expected_chain_id=expected_chain_id, + signing_policy=signing_policy, + ) + + # ── Introspection ───────────────────────────────────────────────── + + @property + def address(self) -> str: + return self._address + + @property + def key_location(self) -> str: + return ( + f"remote:turnkey ({self._api_base_url}; key held in AWS Nitro enclave, never leaves)" + ) + + @property + def signing_policy(self) -> SigningPolicy: + """The SigningPolicy currently enforcing sign_typed_data calls.""" + return self._signing_policy + + @property + def expected_chain_id(self) -> int | None: + """The chain id this provider is pinned to, if any.""" + return self._expected_chain_id + + # ── Signing ─────────────────────────────────────────────────────── + + def _get_client(self) -> TurnkeyClient: + if self._client is None: + self._client = TurnkeyClient( + api_base_url=self._api_base_url, + api_public_key=self._api_public_key, + api_private_key=self._api_private_key, + organization_id=self._organization_id, + ) + return self._client + + def _sign_digest_blind(self, op: str, digest: bytes) -> dict[str, Any]: + """Blind-sign a 32-byte digest (``HEXADECIMAL`` + ``NO_OP``).""" + return self._raw_payload_signature( + op, + payload="0x" + digest.hex(), + encoding="PAYLOAD_ENCODING_HEXADECIMAL", + ) + + def _raw_payload_signature(self, op: str, *, payload: str, encoding: str) -> dict[str, Any]: + client = self._get_client() + try: + result = client.sign_raw_payload( + sign_with=self._address, + payload=payload, + encoding=encoding, + hash_function="HASH_FUNCTION_NO_OP", + ) + except Exception as exc: # noqa: BLE001 - mapped and re-raised + raise _map_vendor_error(op, exc) from exc + r = int(result["r"], 16) + s = int(result["s"], 16) + # The API returns the recovery id ("00"/"01"); normalize to 27/28. + v = int(result["v"], 16) + 27 + signature = HexBytes(r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([v])) + return {"r": r, "s": s, "v": v, "signature": signature} + + def sign_message(self, message: str) -> dict[str, Any]: + """Sign a message using EIP-191 personal sign. + + The digest is hashed locally and blind-signed by the enclave, so + Turnkey's server-side policies cannot see the message content — + content-level control lives in this SDK's client-side policy layer. + """ + digest = defunct_hash_message(text=message) + signed = self._sign_digest_blind("sign_message", bytes(digest)) + return {"messageHash": HexBytes(digest), **signed} + + def sign_typed_data( + self, + domain: dict[str, Any], + types: dict[str, list[dict[str, str]]], + message: dict[str, Any], + ) -> dict[str, Any]: + """Sign EIP-712 typed data after passing the configured SigningPolicy. + + The policy check runs BEFORE the billable API call, so a refusal + costs no quota. The full typed-data document (domain included) goes + to the enclave, where server-side policies can filter on + ``eth.eip_712.domain`` / ``primary_type`` / ``message``. + """ + _policy_check(self._signing_policy, domain, types, message) + message_types = {k: v for k, v in types.items() if k != "EIP712Domain"} + primary_type = infer_primary_type(types) + + # Local digest for the SignatureResult (and signature verification). + signable = encode_typed_data( + domain_data=domain, message_types=message_types, message_data=message + ) + digest = keccak(b"\x19" + signable.version + signable.header + signable.body) + + payload = json.dumps( + { + "types": { + "EIP712Domain": _eip712_domain_types(domain), + **_json_safe(message_types), + }, + "domain": _json_safe(domain), + "primaryType": primary_type, + "message": _json_safe(message), + }, + separators=(",", ":"), + ) + signed = self._raw_payload_signature( + "sign_typed_data", + payload=payload, + encoding="PAYLOAD_ENCODING_EIP712", + ) + return {"messageHash": HexBytes(digest), **signed} + + def sign_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]: + """Sign a legacy or EIP-1559 transaction via the enclave. + + When the provider was constructed with ``expected_chain_id``, a + mismatching ``chainId`` is refused before the billable API call. + """ + chain_id = transaction.get("chainId") + if self._expected_chain_id is not None and chain_id != self._expected_chain_id: + raise ValueError( + f"Refusing to sign for chainId={chain_id}: this Turnkey " + f"provider is pinned to chainId={self._expected_chain_id} " + "(every Turnkey signature is billed, so the mismatch fails " + "closed before the API call)." + ) + unsigned = _serialize_unsigned_transaction(transaction) + client = self._get_client() + try: + signed_hex = client.sign_transaction( + sign_with=self._address, + unsigned_transaction=unsigned.hex(), + ) + except Exception as exc: # noqa: BLE001 - mapped and re-raised + raise _map_vendor_error("sign_transaction", exc) from exc + raw = HexBytes(signed_hex) + parsed = _parse_signed_transaction(bytes(raw)) + return { + "rawTransaction": raw, + "hash": HexBytes(keccak(bytes(raw))), + "r": parsed["r"], + "s": parsed["s"], + "v": parsed["v"], + } diff --git a/python/bnbagent/wallets/turnkey/stamper.py b/python/bnbagent/wallets/turnkey/stamper.py new file mode 100644 index 0000000..1d63c25 --- /dev/null +++ b/python/bnbagent/wallets/turnkey/stamper.py @@ -0,0 +1,99 @@ +"""Turnkey API-key request stamping (X-Stamp header). + +Turnkey authenticates every request by a signature over the exact JSON body: +the caller signs the raw bytes with a locally held P-256 API key (ECDSA / +SHA-256, DER-encoded), wraps it as +``{"publicKey", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature"}`` and +sends it base64url-encoded (padding stripped) in the ``X-Stamp`` header. +Mirrors ``@turnkey/api-key-stamper`` byte-for-byte; the private key never +leaves this process. + +The P-256 primitive comes from the ``cryptography`` package, an **optional +extra** (``pip install 'bnbagent[turnkey]'``) imported lazily at first use — +mirroring the TypeScript side's optional-peer semantics, so ``import +bnbagent`` never requires it. +""" + +from __future__ import annotations + +import base64 +import json + +STAMP_HEADER_NAME = "X-Stamp" +_SIGNATURE_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" + +_INSTALL_HINT = ( + "The Turnkey wallet provider requires the optional 'cryptography' " + "dependency (not installed). Install it with: pip install 'bnbagent[turnkey]'" +) + + +def _load_ec(): + """Import the P-256 primitives lazily, with an actionable error.""" + try: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + except ImportError as exc: # pragma: no cover - exercised via unit test + raise RuntimeError(_INSTALL_HINT) from exc + return ec, hashes + + +def _strip_hex(value: str) -> str: + return value[2:] if value.startswith(("0x", "0X")) else value + + +class ApiKeyStamper: + """Stamps request bodies with a Turnkey P-256 API key pair. + + Args: + api_public_key: Compressed P-256 public key, hex (33 bytes / 66 hex + chars, ``02``/``03`` prefix). + api_private_key: Raw P-256 private scalar, hex (32 bytes). + + Raises: + RuntimeError: If ``cryptography`` is not installed, or the public key + does not match the private key (swapped/wrong credentials fail + here with a clear message instead of an opaque 401). + """ + + def __init__(self, *, api_public_key: str, api_private_key: str) -> None: + ec, hashes = _load_ec() + self._hashes = hashes + self._ec = ec + self._api_public_key = _strip_hex(api_public_key).lower() + try: + self._key = ec.derive_private_key(int(_strip_hex(api_private_key), 16), ec.SECP256R1()) + except ValueError as exc: + raise RuntimeError( + f"TURNKEY_API_PRIVATE_KEY is not a valid P-256 private key hex: {exc}" + ) from exc + + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + + derived = self._key.public_key().public_bytes(Encoding.X962, PublicFormat.CompressedPoint) + if derived.hex() != self._api_public_key: + raise RuntimeError( + "TURNKEY_API_PUBLIC_KEY does not match TURNKEY_API_PRIVATE_KEY " + "(the compressed public key derived from the private key differs) " + "— check that both halves come from the same Turnkey API key." + ) + + def stamp(self, payload: str) -> tuple[str, str]: + """Sign ``payload`` (the exact request body string) for ``X-Stamp``. + + Returns: + ``(header_name, header_value)``. + """ + signature = self._key.sign(payload.encode("utf-8"), self._ec.ECDSA(self._hashes.SHA256())) + stamp = { + "publicKey": self._api_public_key, + "scheme": _SIGNATURE_SCHEME, + "signature": signature.hex(), + } + encoded = base64.urlsafe_b64encode( + json.dumps(stamp, separators=(",", ":")).encode("utf-8") + ) + return STAMP_HEADER_NAME, encoded.decode("ascii").rstrip("=") diff --git a/python/pyproject.toml b/python/pyproject.toml index 7ae6f0f..360e4d1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -48,12 +48,17 @@ Issues = "https://github.com/bnb-chain/bnbagent-sdk/issues" ipfs = [ "httpx>=0.25.0", ] +turnkey = [ + "cryptography>=42.0.0", +] dev = [ "pytest>=7.4.0", "pytest-mock>=3.11.0", "pytest-asyncio>=0.23.0", "httpx>=0.25.0", "ruff>=0.4.0", + # Exercises the turnkey extra's stamper in CI. + "cryptography>=42.0.0", ] examples = [ "aiosqlite>=0.19", diff --git a/python/tests/test_erc8183_config.py b/python/tests/test_erc8183_config.py index 1faef16..d71b5b0 100644 --- a/python/tests/test_erc8183_config.py +++ b/python/tests/test_erc8183_config.py @@ -4,13 +4,65 @@ import pytest -from bnbagent.erc8183.config import ERC8183Config from bnbagent.config import NetworkConfig +from bnbagent.erc8183.config import ERC8183Config VALID_PK = "0x" + "cd" * 32 VALID_PASSWORD = "test-password" +class TestWalletKindTurnkey: + """WALLET_KIND=turnkey dispatches to TurnkeyWalletProvider.from_env + pinned to the config network's chain id (TS ERC8183Config parity).""" + + SIGN_WITH = "0x" + "7a" * 20 + + def _set_env(self, monkeypatch): + monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32) + monkeypatch.setenv("TURNKEY_API_PRIVATE_KEY", "cd" * 32) + monkeypatch.setenv("TURNKEY_ORG_ID", "org-config") + monkeypatch.setenv("TURNKEY_SIGN_WITH", self.SIGN_WITH) + + def test_dispatches_pinned_to_network_chain_id(self, monkeypatch): + from bnbagent.wallets.turnkey import TurnkeyWalletProvider + + self._set_env(monkeypatch) + config = ERC8183Config(wallet_kind="turnkey") + assert isinstance(config.wallet_provider, TurnkeyWalletProvider) + assert config.wallet_provider.expected_chain_id == 97 + assert config.wallet_provider.address.lower() == self.SIGN_WITH + + def test_missing_env_names_every_var(self, monkeypatch): + for key in ( + "TURNKEY_API_PUBLIC_KEY", + "TURNKEY_API_PRIVATE_KEY", + "TURNKEY_ORG_ID", + "TURNKEY_SIGN_WITH", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32) + with pytest.raises( + ValueError, match="TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH" + ): + ERC8183Config(wallet_kind="turnkey") + + def test_wallet_address_anchor_drift_fails_closed(self, monkeypatch): + from bnbagent.wallets.errors import WalletIdentityMismatch + + self._set_env(monkeypatch) + with pytest.raises(WalletIdentityMismatch): + ERC8183Config(wallet_kind="turnkey", wallet_address="0x" + "9b" * 20) + + def test_matching_anchor_and_no_password_required(self, monkeypatch): + self._set_env(monkeypatch) + monkeypatch.delenv("WALLET_PASSWORD", raising=False) + config = ERC8183Config( + wallet_kind="turnkey", wallet_address=self.SIGN_WITH.upper().replace("0X", "0x") + ) + assert config.wallet_provider is not None + assert config.wallet_provider.kind == "turnkey" + + class TestInit: def test_valid_config_with_wallet_password(self): config = ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD) @@ -96,17 +148,13 @@ def test_private_key_cleared_after_wrap(self): assert config.private_key == "" assert VALID_PK not in repr(config) - def test_warns_when_private_key_env_persists_after_wrap( - self, monkeypatch, caplog - ): + def test_warns_when_private_key_env_persists_after_wrap(self, monkeypatch, caplog): import logging monkeypatch.setenv("PRIVATE_KEY", VALID_PK) with caplog.at_level(logging.WARNING, logger="bnbagent.core.config"): ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD) - assert any( - "PRIVATE_KEY is still set" in r.message for r in caplog.records - ) + assert any("PRIVATE_KEY is still set" in r.message for r in caplog.records) def test_no_env_warning_when_private_key_unset(self, monkeypatch, caplog): import logging @@ -114,9 +162,7 @@ def test_no_env_warning_when_private_key_unset(self, monkeypatch, caplog): monkeypatch.delenv("PRIVATE_KEY", raising=False) with caplog.at_level(logging.WARNING, logger="bnbagent.core.config"): ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD) - assert not any( - "PRIVATE_KEY is still set" in r.message for r in caplog.records - ) + assert not any("PRIVATE_KEY is still set" in r.message for r in caplog.records) def test_repr_with_wallet_provider(self): mock_wallet = MagicMock() diff --git a/python/tests/test_turnkey_provider.py b/python/tests/test_turnkey_provider.py new file mode 100644 index 0000000..d7ed176 --- /dev/null +++ b/python/tests/test_turnkey_provider.py @@ -0,0 +1,454 @@ +"""``TurnkeyWalletProvider`` (Python) — provider-specific suite. + +Mirrors ``typescript/tests/turnkeyProvider.test.ts``: construction +validation, ``from_env``, policy-before-billing ordering, the EIP-712 +payload contract (domain included by construction — the ``@turnkey/viem`` +0.14.x stripping trap, immunized here and pinned by the fake enclave), +legacy/1559 transaction round-trips, vendor error mapping, and the +stamper's wire format. The fake enclave signs with ``eth_account``, so +every signature is real and recoverable. +""" + +from __future__ import annotations + +import base64 +import json +import sys +import time + +import pytest +from eth_account import Account +from eth_account.messages import defunct_hash_message, encode_defunct, encode_typed_data +from eth_utils import keccak, to_checksum_address + +from bnbagent.signing import PolicyViolation, SigningPolicy +from bnbagent.wallets.capabilities import ( + CALLS_ARBITRARY, + PAYMASTER_SPONSOR, + SIGN_MESSAGE, + SIGN_TRANSACTION, + SIGN_TYPED_DATA, +) +from bnbagent.wallets.turnkey import ( + TURNKEY_API_BASE_URL_DEFAULT, + TurnkeyApiError, + TurnkeyWalletProvider, +) +from bnbagent.wallets.turnkey.stamper import ApiKeyStamper + +from .turnkey_fake import FakeTurnkeyClient + +# The Turnkey-hosted key the fake "enclave" signs with; SIGN_WITH is its +# address, so signatures recover to the provider address. +TEST_PK = "0x" + "c3" * 32 +SIGN_WITH = Account.from_key(TEST_PK).address + + +def _p256_fixture() -> tuple[str, str]: + """Deterministic P-256 key pair (private hex, compressed public hex).""" + ec = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ec") + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + + private = ( + int("d9" * 32, 16) + % (0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 - 1) + + 1 + ) + key = ec.derive_private_key(private, ec.SECP256R1()) + public = key.public_key().public_bytes(Encoding.X962, PublicFormat.CompressedPoint) + return format(private, "064x"), public.hex() + + +BASE_KWARGS = { + "organization_id": "org-123", + "sign_with": SIGN_WITH, + "api_public_key": "02" + "ab" * 32, + "api_private_key": "cd" * 32, +} + +# A domain NOT in known_payment_tokens — strict_default must refuse it. +TEST_DOMAIN = { + "name": "TestToken", + "version": "1", + "chainId": 97, + "verifyingContract": to_checksum_address("0x" + "22" * 20), +} + + +def eip3009_fixture() -> tuple[dict, dict]: + now = int(time.time()) + types = { + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + } + message = { + "from": SIGN_WITH, + "to": to_checksum_address("0x" + "33" * 20), + # Big enough to exercise the decimal-string JSON spelling. + "value": 10**18, + "validAfter": now - 10, + "validBefore": now + 580, + "nonce": "0x" + "44" * 32, + } + return types, message + + +def extended_policy() -> SigningPolicy: + return SigningPolicy.strict_default().extend( + domain_allowlist={(97, TEST_DOMAIN["verifyingContract"])} + ) + + +@pytest.fixture +def fake_client() -> FakeTurnkeyClient: + return FakeTurnkeyClient(TEST_PK) + + +def make_provider(fake_client: FakeTurnkeyClient, **overrides) -> TurnkeyWalletProvider: + kwargs = {**BASE_KWARGS, "client": fake_client, **overrides} + return TurnkeyWalletProvider(**kwargs) + + +TURNKEY_ENV = { + "TURNKEY_API_PUBLIC_KEY": BASE_KWARGS["api_public_key"], + "TURNKEY_API_PRIVATE_KEY": BASE_KWARGS["api_private_key"], + "TURNKEY_ORG_ID": BASE_KWARGS["organization_id"], + "TURNKEY_SIGN_WITH": SIGN_WITH, +} + + +# ── construction ────────────────────────────────────────────────────── + + +class TestConstruction: + def test_capability_surface(self, fake_client): + provider = make_provider(fake_client) + assert provider.capabilities() == frozenset( + { + SIGN_MESSAGE, + SIGN_TRANSACTION, + SIGN_TYPED_DATA, + CALLS_ARBITRARY, + PAYMASTER_SPONSOR, + } + ) + assert provider.kind == "turnkey" + + def test_address_is_checksummed_from_lowercase(self, fake_client): + provider = make_provider(fake_client, sign_with=SIGN_WITH.lower()) + assert provider.address == SIGN_WITH + + def test_describe_reports_remote_key_location_without_secrets(self, fake_client): + provider = make_provider(fake_client) + info = provider.describe() + assert info["address"] == SIGN_WITH + assert "remote:turnkey" in info["key_location"] + assert TURNKEY_API_BASE_URL_DEFAULT in info["key_location"] + assert BASE_KWARGS["api_private_key"] not in json.dumps(info) + + @pytest.mark.parametrize( + "sign_with", + [ + "3f2504e0-4f89-11d3-9a0c-0305e82c3301", # Turnkey wallet id (UUID) + "pk-12345", # private-key id + "0x" + "ab" * 19 + "a", # 39 hex chars + ], + ) + def test_rejects_non_address_sign_with(self, fake_client, sign_with): + with pytest.raises(ValueError, match="Ethereum\\s+.*address|Ethereum "): + make_provider(fake_client, sign_with=sign_with) + + @pytest.mark.parametrize( + "field", ["organization_id", "sign_with", "api_public_key", "api_private_key"] + ) + def test_requires_non_empty_credentials(self, fake_client, field): + with pytest.raises(ValueError, match=f"{field!r} is required"): + make_provider(fake_client, **{field: ""}) + + def test_construction_is_offline(self, fake_client): + provider = make_provider(fake_client) + provider.describe() + assert fake_client.raw_payload_calls == [] + assert fake_client.transaction_calls == [] + + +# ── from_env ────────────────────────────────────────────────────────── + + +class TestFromEnv: + def test_builds_from_the_four_env_vars(self, monkeypatch): + for key, value in TURNKEY_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("TURNKEY_API_BASE_URL", raising=False) + provider = TurnkeyWalletProvider.from_env(expected_chain_id=97) + assert provider.address == SIGN_WITH + assert provider.expected_chain_id == 97 + assert TURNKEY_API_BASE_URL_DEFAULT in provider.key_location + + def test_honors_base_url_and_policy(self, monkeypatch): + for key, value in TURNKEY_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("TURNKEY_API_BASE_URL", "https://api.turnkey.example") + policy = extended_policy() + provider = TurnkeyWalletProvider.from_env(signing_policy=policy) + assert "https://api.turnkey.example" in provider.key_location + assert provider.signing_policy is policy + + def test_names_all_missing_env_vars(self, monkeypatch): + for key in TURNKEY_ENV: + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32) + with pytest.raises( + ValueError, + match=( + "missing required env vars: TURNKEY_API_PRIVATE_KEY, " + "TURNKEY_ORG_ID, TURNKEY_SIGN_WITH" + ), + ): + TurnkeyWalletProvider.from_env() + + +# ── sign_message (EIP-191) ──────────────────────────────────────────── + + +class TestSignMessage: + def test_round_trip_recovers_to_provider_address(self, fake_client): + provider = make_provider(fake_client) + result = provider.sign_message("hello turnkey") + assert bytes(result["messageHash"]) == bytes(defunct_hash_message(text="hello turnkey")) + assert result["v"] in (27, 28) + recovered = Account.recover_message( + encode_defunct(text="hello turnkey"), signature=result["signature"] + ) + assert recovered == provider.address + # Blind digest path: the enclave saw HEXADECIMAL + NO_OP. + (call,) = fake_client.raw_payload_calls + assert call["encoding"] == "PAYLOAD_ENCODING_HEXADECIMAL" + assert call["hash_function"] == "HASH_FUNCTION_NO_OP" + + +# ── sign_typed_data (EIP-712) ───────────────────────────────────────── + + +class TestSignTypedData: + def test_policy_check_runs_before_any_billable_call(self, fake_client): + provider = make_provider(fake_client) + types, message = eip3009_fixture() + with pytest.raises(PolicyViolation): + provider.sign_typed_data(TEST_DOMAIN, types, message) + assert fake_client.raw_payload_calls == [] + + def test_signs_after_policy_extension(self, fake_client): + provider = make_provider(fake_client, signing_policy=extended_policy()) + types, message = eip3009_fixture() + result = provider.sign_typed_data(TEST_DOMAIN, types, message) + assert result["v"] in (27, 28) + + def test_payload_carries_the_full_eip712_domain(self, fake_client): + # The Python provider builds the enclave payload itself, so the + # @turnkey/viem stripping trap cannot occur — pinned here. + provider = make_provider(fake_client, signing_policy=extended_policy()) + types, message = eip3009_fixture() + provider.sign_typed_data(TEST_DOMAIN, types, message) + (call,) = fake_client.raw_payload_calls + assert call["encoding"] == "PAYLOAD_ENCODING_EIP712" + document = json.loads(call["payload"]) + assert document["types"]["EIP712Domain"] == [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ] + assert document["domain"]["chainId"] == 97 # small int stays a number + assert document["message"]["value"] == str(10**18) # big int → string + assert document["primaryType"] == "TransferWithAuthorization" + + def test_replaces_caller_supplied_eip712_domain(self, fake_client): + provider = make_provider(fake_client, signing_policy=extended_policy()) + types, message = eip3009_fixture() + with_bogus = {"EIP712Domain": [{"name": "name", "type": "string"}], **types} + first = provider.sign_typed_data(TEST_DOMAIN, with_bogus, message) + second = provider.sign_typed_data(TEST_DOMAIN, types, message) + assert bytes(first["signature"]) == bytes(second["signature"]) + for call in fake_client.raw_payload_calls: + document = json.loads(call["payload"]) + assert len(document["types"]["EIP712Domain"]) == 4 + + def test_binds_the_real_domain_and_reports_the_712_digest(self, fake_client): + provider = make_provider(fake_client, signing_policy=extended_policy()) + types, message = eip3009_fixture() + result = provider.sign_typed_data(TEST_DOMAIN, types, message) + signable = encode_typed_data( + domain_data=TEST_DOMAIN, + message_types={k: v for k, v in types.items() if k != "EIP712Domain"}, + message_data=message, + ) + expected_digest = keccak(b"\x19" + signable.version + signable.header + signable.body) + assert bytes(result["messageHash"]) == expected_digest + recovered = Account.recover_message(signable, signature=result["signature"]) + assert recovered == provider.address + + def test_rejects_multi_struct_types_before_any_billable_call(self, fake_client): + provider = make_provider(fake_client, signing_policy=extended_policy()) + types, message = eip3009_fixture() + multi = {**types, "Extra": [{"name": "x", "type": "uint256"}]} + with pytest.raises(PolicyViolation): + provider.sign_typed_data(TEST_DOMAIN, multi, message) + assert fake_client.raw_payload_calls == [] + + +# ── sign_transaction ────────────────────────────────────────────────── + + +LEGACY_TX = { + "chainId": 97, + "to": to_checksum_address("0x" + "55" * 20), + "value": 1, + "nonce": 0, + "gas": 21_000, + "gasPrice": 10_000_000_000, +} + + +class TestSignTransaction: + def test_legacy_round_trip(self, fake_client): + provider = make_provider(fake_client) + signed = provider.sign_transaction(dict(LEGACY_TX)) + raw = bytes(signed["rawTransaction"]) + assert bytes(signed["hash"]) == keccak(raw) + # EIP-155 v for chainId 97: 2*97 + 35/36. + assert signed["v"] in (229, 230) + assert Account.recover_transaction(raw) == provider.address + + def test_eip1559_round_trip(self, fake_client): + provider = make_provider(fake_client) + tx = { + "chainId": 97, + "to": to_checksum_address("0x" + "55" * 20), + "value": 1, + "nonce": 0, + "gas": 21_000, + "maxFeePerGas": 10_000_000_000, + "maxPriorityFeePerGas": 1_000_000_000, + } + signed = provider.sign_transaction(tx) + raw = bytes(signed["rawTransaction"]) + assert raw[:1] == b"\x02" + # eth-account semantics: typed transactions report the y-parity bit. + assert signed["v"] in (0, 1) + assert Account.recover_transaction(raw) == provider.address + + def test_gas_price_zero_paymaster_shape(self, fake_client): + # MegaFuel sponsorship signs gasPrice=0 legacy transactions + # (enclave-verified, probe 2026-07-27); the codec must not drop the + # canonical zero. + provider = make_provider(fake_client) + signed = provider.sign_transaction({**LEGACY_TX, "gasPrice": 0}) + assert Account.recover_transaction(bytes(signed["rawTransaction"])) + assert signed["v"] in (229, 230) + + def test_contract_creation_omits_to(self, fake_client): + provider = make_provider(fake_client) + tx = {**LEGACY_TX, "to": None, "data": "0x6001600155"} + signed = provider.sign_transaction(tx) + assert Account.recover_transaction(bytes(signed["rawTransaction"])) + + def test_refuses_chain_id_mismatch_before_the_billable_call(self, fake_client): + provider = make_provider(fake_client, expected_chain_id=97) + with pytest.raises(ValueError, match="pinned to chainId=97"): + provider.sign_transaction({**LEGACY_TX, "chainId": 56}) + assert fake_client.transaction_calls == [] + assert provider.expected_chain_id == 97 + + def test_rejects_access_list_transactions(self, fake_client): + provider = make_provider(fake_client) + with pytest.raises(ValueError, match="accessList"): + provider.sign_transaction({**LEGACY_TX, "accessList": [{"address": "0x" + "11" * 20}]}) + + +# ── vendor error mapping ────────────────────────────────────────────── + + +class TestVendorErrorMapping: + def test_quota_exhaustion_hint(self, fake_client): + provider = make_provider(fake_client) + fake_client.failure = TurnkeyApiError( + "SIGNING_QUOTA_EXCEEDED for organization", status_code=429 + ) + with pytest.raises(RuntimeError, match="25 billed signatures/month"): + provider.sign_message("x") + + def test_rate_limit_hint(self, fake_client): + provider = make_provider(fake_client) + fake_client.failure = TurnkeyApiError( + "Turnkey error 8: RATE_LIMIT_EXCEEDED", status_code=429 + ) + with pytest.raises(RuntimeError, match="1 request/second"): + provider.sign_message("x") + + def test_policy_denied_hint(self, fake_client): + provider = make_provider(fake_client) + fake_client.failure = TurnkeyApiError( + "policy engine rejected the activity (POLICY_REJECTED)", + status_code=403, + ) + with pytest.raises(RuntimeError, match="explicit ALLOW policy"): + provider.sign_message("x") + + def test_unrecognized_errors_pass_through(self, fake_client): + provider = make_provider(fake_client) + original = TurnkeyApiError("something else entirely") + fake_client.failure = original + with pytest.raises(TurnkeyApiError) as excinfo: + provider.sign_message("x") + assert excinfo.value is original + + +# ── stamper wire format ─────────────────────────────────────────────── + + +class TestStamper: + def test_stamp_shape_and_signature_verify(self): + private_hex, public_hex = _p256_fixture() + stamper = ApiKeyStamper(api_public_key=public_hex, api_private_key=private_hex) + header_name, header_value = stamper.stamp('{"probe":true}') + assert header_name == "X-Stamp" + assert "=" not in header_value # base64url padding stripped + padded = header_value + "=" * (-len(header_value) % 4) + stamp = json.loads(base64.urlsafe_b64decode(padded)) + assert stamp["scheme"] == "SIGNATURE_SCHEME_TK_API_P256" + assert stamp["publicKey"] == public_hex + + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + + public_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), bytes.fromhex(public_hex) + ) + # Raises InvalidSignature on mismatch — DER over the exact bytes. + public_key.verify( + bytes.fromhex(stamp["signature"]), + b'{"probe":true}', + ec.ECDSA(hashes.SHA256()), + ) + + def test_mismatched_key_pair_fails_fast(self): + private_hex, _ = _p256_fixture() + with pytest.raises(RuntimeError, match="does not match"): + ApiKeyStamper(api_public_key="02" + "ab" * 32, api_private_key=private_hex) + + def test_missing_cryptography_yields_install_hint(self, monkeypatch): + monkeypatch.setitem(sys.modules, "cryptography", None) + monkeypatch.setitem(sys.modules, "cryptography.hazmat", None) + monkeypatch.setitem(sys.modules, "cryptography.hazmat.primitives", None) + with pytest.raises(RuntimeError, match="bnbagent\\[turnkey\\]"): + ApiKeyStamper(api_public_key="02" + "ab" * 32, api_private_key="cd" * 32) diff --git a/python/tests/test_wallet_conformance.py b/python/tests/test_wallet_conformance.py index 92a7e30..d975507 100644 --- a/python/tests/test_wallet_conformance.py +++ b/python/tests/test_wallet_conformance.py @@ -34,6 +34,7 @@ from bnbagent.wallets import ( EVMWalletProvider, ExecutionContext, + TurnkeyWalletProvider, TWAKProvider, UnsupportedWalletOperation, WalletProvider, @@ -51,6 +52,8 @@ ) from bnbagent.wallets.local_executor import LocalExecutor +from .turnkey_fake import FakeTurnkeyClient + PW = "test-secure-password-123" _TEST_KEY = "0x" + "ab" * 32 @@ -65,6 +68,9 @@ "twak": frozenset( {SIGN_MESSAGE, BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY} ), + "turnkey": frozenset( + {SIGN_MESSAGE, SIGN_TRANSACTION, SIGN_TYPED_DATA, CALLS_ARBITRARY, PAYMASTER_SPONSOR} + ), } @@ -94,6 +100,19 @@ def _make_provider(kind: str) -> WalletProvider: persist=False, signing_policy=SigningPolicy.permissive(), ) + if kind == "turnkey": + # Remote signer with the fake in-process enclave (signs with the + # shared test key, never dials the network). Permissive policy for + # the same reason as evm above. + fake = FakeTurnkeyClient(_TEST_KEY) + return TurnkeyWalletProvider( + organization_id="org-conformance", + sign_with=fake.address, + api_public_key="02" + "ab" * 32, + api_private_key="cd" * 32, + signing_policy=SigningPolicy.permissive(), + client=fake, + ) return TWAKProvider(chain="bsc") diff --git a/python/tests/test_wallet_factory.py b/python/tests/test_wallet_factory.py index 958f372..261171d 100644 --- a/python/tests/test_wallet_factory.py +++ b/python/tests/test_wallet_factory.py @@ -55,7 +55,20 @@ def test_mpc_is_not_implemented(self): create_wallet_provider("mpc") def test_supported_kinds_match_class_attrs(self): - assert set(SUPPORTED_WALLET_KINDS) == {"evm", "twak", "mpc"} + assert set(SUPPORTED_WALLET_KINDS) == {"evm", "twak", "mpc", "turnkey"} + + def test_turnkey_dispatches_to_provider(self): + from bnbagent.wallets.turnkey import TurnkeyWalletProvider + + wallet = create_wallet_provider( + "turnkey", + organization_id="org-123", + sign_with="0x" + "7a" * 20, + api_public_key="02" + "ab" * 32, + api_private_key="cd" * 32, + ) + assert isinstance(wallet, TurnkeyWalletProvider) + assert wallet.kind == "turnkey" class TestIntrospection: diff --git a/python/tests/turnkey_fake.py b/python/tests/turnkey_fake.py new file mode 100644 index 0000000..c4f23c0 --- /dev/null +++ b/python/tests/turnkey_fake.py @@ -0,0 +1,164 @@ +"""A faithful in-process fake of the Turnkey signing enclave. + +Implements the :class:`bnbagent.wallets.turnkey.client.TurnkeyClient` +surface with ``eth_account`` as the "enclave": signatures are real and +recoverable, and the EIP-712 path re-parses the provider's JSON payload +with an independent implementation — so a malformed payload (e.g. a missing +``EIP712Domain`` entry) fails here the same way it would fail verification +against the real service. + +Never dials the network; used by the provider and conformance suites. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +import rlp +from eth_account import Account +from eth_utils import to_checksum_address + +_DECIMAL_RE = re.compile(r"^\d+$") + + +def _coerce_document(document: dict[str, Any]) -> dict[str, Any]: + """Undo the provider's big-int→decimal-string JSON conversion, type-aware. + + The real enclave parses either spelling for numeric fields (proven by + the 2026-07 probes, where ``@turnkey/viem`` sent JS bigints as + strings); ``eth_account``'s encoder wants ints. Coercion follows the + declared field types — a ``string`` field that happens to contain + digits (e.g. ``version: "1"``) must stay a string. + """ + types: dict[str, list[dict[str, str]]] = document.get("types", {}) + + def coerce_value(field_type: str, value: Any) -> Any: + if field_type.endswith("]"): + base = field_type[: field_type.rindex("[")] + return [coerce_value(base, item) for item in value] + if field_type in types: + return coerce_struct(field_type, value) + if ( + field_type.startswith(("uint", "int")) + and isinstance(value, str) + and _DECIMAL_RE.match(value) + ): + return int(value) + return value + + def coerce_struct(struct_name: str, value: dict[str, Any]) -> dict[str, Any]: + fields = {f["name"]: f["type"] for f in types.get(struct_name, [])} + return {key: coerce_value(fields.get(key, ""), item) for key, item in value.items()} + + return { + **document, + "domain": coerce_struct("EIP712Domain", document.get("domain", {})), + "message": coerce_struct(str(document.get("primaryType")), document.get("message", {})), + } + + +def _int_of(field: bytes) -> int: + return int.from_bytes(field, "big") if field else 0 + + +def _decode_unsigned_transaction(raw: bytes) -> dict[str, Any]: + """Decode the provider's unsigned RLP back into an eth_account tx dict.""" + if raw[:1] == b"\x02": + fields = rlp.decode(raw[1:]) + assert len(fields) == 9, f"unexpected 1559 unsigned shape: {len(fields)}" + tx: dict[str, Any] = { + "type": 2, + "chainId": _int_of(fields[0]), + "nonce": _int_of(fields[1]), + "maxPriorityFeePerGas": _int_of(fields[2]), + "maxFeePerGas": _int_of(fields[3]), + "gas": _int_of(fields[4]), + "value": _int_of(fields[6]), + "data": "0x" + fields[7].hex(), + "accessList": [], + } + if fields[5]: + tx["to"] = to_checksum_address("0x" + fields[5].hex()) + return tx + fields = rlp.decode(raw) + assert len(fields) == 9, f"unexpected legacy unsigned shape: {len(fields)}" + assert _int_of(fields[7]) == 0 and _int_of(fields[8]) == 0, ( + "legacy unsigned payload must end with the EIP-155 (chainId, 0, 0) triplet" + ) + tx = { + "nonce": _int_of(fields[0]), + "gasPrice": _int_of(fields[1]), + "gas": _int_of(fields[2]), + "value": _int_of(fields[4]), + "data": "0x" + fields[5].hex(), + "chainId": _int_of(fields[6]), + } + if fields[3]: + tx["to"] = to_checksum_address("0x" + fields[3].hex()) + return tx + + +class FakeTurnkeyClient: + """Signs like the enclave, records like a probe. + + Attributes: + raw_payload_calls: Every ``sign_raw_payload`` invocation's kwargs. + transaction_calls: Every ``sign_transaction`` unsigned hex. + failure: When set, the next call raises it (vendor-error testing). + """ + + def __init__(self, private_key: str) -> None: + self._account = Account.from_key(private_key) + self.raw_payload_calls: list[dict[str, Any]] = [] + self.transaction_calls: list[str] = [] + self.failure: Exception | None = None + + @property + def address(self) -> str: + return self._account.address + + def _maybe_fail(self) -> None: + if self.failure is not None: + failure, self.failure = self.failure, None + raise failure + + def sign_raw_payload( + self, *, sign_with: str, payload: str, encoding: str, hash_function: str + ) -> dict[str, str]: + self._maybe_fail() + self.raw_payload_calls.append( + { + "sign_with": sign_with, + "payload": payload, + "encoding": encoding, + "hash_function": hash_function, + } + ) + assert hash_function == "HASH_FUNCTION_NO_OP", hash_function + if encoding == "PAYLOAD_ENCODING_HEXADECIMAL": + digest = bytes.fromhex(payload[2:] if payload.startswith("0x") else payload) + signed = self._account.unsafe_sign_hash(digest) + elif encoding == "PAYLOAD_ENCODING_EIP712": + document = json.loads(payload) + assert "EIP712Domain" in document.get("types", {}), ( + "typed-data payload reached the enclave WITHOUT an " + "EIP712Domain entry — the 0.14.x stripping trap" + ) + signed = self._account.sign_typed_data(full_message=_coerce_document(document)) + else: # pragma: no cover - guards fake misuse + raise AssertionError(f"unexpected encoding {encoding!r}") + return { + "r": format(signed.r, "064x"), + "s": format(signed.s, "064x"), + # The API returns the recovery id, not 27/28. + "v": format(signed.v - 27, "02x"), + } + + def sign_transaction(self, *, sign_with: str, unsigned_transaction: str) -> str: + self._maybe_fail() + self.transaction_calls.append(unsigned_transaction) + tx = _decode_unsigned_transaction(bytes.fromhex(unsigned_transaction)) + signed = self._account.sign_transaction(tx) + return bytes(signed.raw_transaction).hex() diff --git a/python/uv.lock b/python/uv.lock index 367c538..39f4bd5 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -310,6 +310,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "cryptography" }, { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -326,10 +327,15 @@ examples = [ ipfs = [ { name = "httpx" }, ] +turnkey = [ + { name = "cryptography" }, +] [package.metadata] requires-dist = [ { name = "aiosqlite", marker = "extra == 'examples'", specifier = ">=0.19" }, + { name = "cryptography", marker = "extra == 'dev'", specifier = ">=42.0.0" }, + { name = "cryptography", marker = "extra == 'turnkey'", specifier = ">=42.0.0" }, { name = "ddgs", marker = "extra == 'examples'", specifier = ">=6.0" }, { name = "eth-account", specifier = ">=0.10.0" }, { name = "fastapi", marker = "extra == 'examples'", specifier = ">=0.104.0" }, @@ -345,7 +351,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'examples'", specifier = ">=0.24.0" }, { name = "web3", specifier = ">=6.15.0" }, ] -provides-extras = ["dev", "examples", "ipfs"] +provides-extras = ["dev", "examples", "ipfs", "turnkey"] [[package]] name = "certifi" @@ -356,6 +362,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -552,6 +668,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cytoolz" version = "1.1.0" @@ -1499,6 +1672,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" From 618081c26407463159cf48e7f01f9e984413b608 Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:22:21 +0800 Subject: [PATCH 5/7] docs+chore: turnkey e2e examples, README coverage, bump TS 0.5.1 / Py 0.5.0 Env-gated live E2Es (TS 5 / Py 4 billed signatures, serial >=1.1s, chain-id assert before anything billable), wallet-matrix rows in the three READMEs with the root-key and per-signature-billing warnings. --- README.md | 5 +- python/bnbagent/_version.py | 2 +- python/bnbagent/wallets/README.md | 23 +++ python/examples/turnkey_e2e.py | 192 +++++++++++++++++++++++ typescript/README.md | 35 +++-- typescript/examples/turnkey/e2e.ts | 241 +++++++++++++++++++++++++++++ typescript/package.json | 2 +- 7 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 python/examples/turnkey_e2e.py create mode 100644 typescript/examples/turnkey/e2e.ts diff --git a/README.md b/README.md index c77d6fc..7d5d175 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,16 @@ Shared, language-neutral material lives at the root: ## Wallets -Every protocol client signs through the `WalletProvider` seam, so the wallet is a construction-time choice. Three backends ship today: +Every protocol client signs through the `WalletProvider` seam, so the wallet is a construction-time choice. Four backends ship today: | Wallet | Custody | SDK | Notes | | --- | --- | --- | --- | | `EVMWalletProvider` | local key (Keystore V3 on disk) | Python + TypeScript | full signing surface (`sign.message/transaction/typed_data`), MegaFuel paymaster support | | `TWAKProvider` | [Trust Wallet Agent Kit](./docs/twak.md) CLI (`twak` >= v0.20.0) | Python + TypeScript | self-broadcasting; ERC-8004/8183 intents + delegated x402; sponsored testnet writes via `--paymaster-url` | | `AltanaWalletProvider` | [Altana](./docs/altana.md) EIP-7702 wallet, on-chain session keys | TypeScript | self-broadcasting via relay; session-key x402 payer (Altana SDK >= 0.4.0); testnet preset, balances + ephemeral sessions (>= 0.5.0) | +| `TurnkeyWalletProvider` | [Turnkey](https://docs.turnkey.com) remote signing — keys live in AWS Nitro enclaves, the agent holds only a P-256 API key | Python + TypeScript | full signing surface + MegaFuel paymaster; every successful signature is billed (free tier 25/month at 1 req/s, PAYG $0.10); production requires a non-root API user + explicit ALLOW policy (root keys bypass all Turnkey policies) | -Details: [`python/bnbagent/wallets/README.md`](./python/bnbagent/wallets/README.md) (EVM + TWAK), [`typescript/README.md`](./typescript/README.md) (EVM + TWAK + Altana), [`docs/twak.md`](./docs/twak.md), and [`docs/altana.md`](./docs/altana.md). +Details: [`python/bnbagent/wallets/README.md`](./python/bnbagent/wallets/README.md) (EVM + TWAK + Turnkey), [`typescript/README.md`](./typescript/README.md) (EVM + TWAK + Altana + Turnkey), [`docs/twak.md`](./docs/twak.md), and [`docs/altana.md`](./docs/altana.md). ## Getting started diff --git a/python/bnbagent/_version.py b/python/bnbagent/_version.py index ae4e3f1..79c117c 100644 --- a/python/bnbagent/_version.py +++ b/python/bnbagent/_version.py @@ -3,4 +3,4 @@ Hatchling reads this file via [tool.hatch.version] in pyproject.toml, so changing the literal below is the only thing needed to bump versions. """ -__version__ = "0.4.2" +__version__ = "0.5.0" diff --git a/python/bnbagent/wallets/README.md b/python/bnbagent/wallets/README.md index 11942eb..99de6b9 100644 --- a/python/bnbagent/wallets/README.md +++ b/python/bnbagent/wallets/README.md @@ -92,6 +92,7 @@ There is **no shared key store** across providers - each owns its own custody, a | `EVMWalletProvider` | `evm` | `~/.bnbagent/wallets/
.json` (Keystore V3) | | `TWAKProvider` | `twak` | `/.twak/wallet.json` (encrypted mnemonic) + OS keychain / `TWAK_WALLET_PASSWORD` | | `MPCWalletProvider` | `mpc` | external MPC enclave (subclass-defined) | +| `TurnkeyWalletProvider` | `turnkey` | [Turnkey](https://docs.turnkey.com)'s AWS Nitro enclave — the key never leaves it; the agent holds only a local P-256 API key | The twak CLI exposes no private-key `import`/`export` or `--keystore-path`, so its key cannot be shared with the SDK keystore (or vice versa). Treat "choose a provider" as "choose a custodian"; use `describe()` / `key_location` to report where a wallet's key lives without unifying storage. @@ -102,6 +103,7 @@ from bnbagent.wallets import create_wallet_provider wallet = create_wallet_provider("evm", password="pw") # -> EVMWalletProvider wallet = create_wallet_provider("twak", chain="bsc") # -> TWAKProvider +wallet = TurnkeyWalletProvider.from_env() # -> TurnkeyWalletProvider (TURNKEY_* env) print(wallet.describe()) # {"kind": "twak", "address": "0x..", "key_location": "...", # "exists": True, "capabilities": ["broadcast.self", ...]} ``` @@ -294,6 +296,27 @@ Default `IntentExecutor`. Builds, signs (via the wrapped `WalletProvider`) and b - `IntentExecutor.execute(intent) -> dict` - returns at least `{"transactionHash", "receipt"}` (executors may add `agentId`, etc.). - `ExecutionContext(web3, paymaster, receipt_timeout)` - the context a pure signer needs to build a `LocalExecutor`. +### `TurnkeyWalletProvider` + +Pure signer over [Turnkey](https://docs.turnkey.com)'s remote key-management API: the private key is generated and held inside Turnkey's AWS Nitro enclave and never leaves it; every sign call is an HTTPS activity stamped by a locally held P-256 API key pair (`pip install 'bnbagent[turnkey]'` for the `cryptography` extra, imported lazily on first use). Same capability surface as `EVMWalletProvider` (`sign.*` x3 + `calls.arbitrary` + `paymaster.sponsor`), so ERC-8004/8183 writes ride the default `LocalExecutor` and x402 rides `X402Signer` unchanged. + +```python +from bnbagent.wallets import TurnkeyWalletProvider + +# env: TURNKEY_API_PUBLIC_KEY / TURNKEY_API_PRIVATE_KEY / TURNKEY_ORG_ID / +# TURNKEY_SIGN_WITH (the wallet account's 0x ADDRESS, not a wallet id) +wallet = TurnkeyWalletProvider.from_env(expected_chain_id=97) +``` + +Operational constraints (the same list as the TypeScript provider): + +- **Every successful signature is billed** — free tier 25/month at 1 request/second; pay-as-you-go $0.10/signature. All SDK-side guards (`SigningPolicy`, the `expected_chain_id` pin, input validation) run *before* the API call, so a refusal never costs quota. +- **Root API keys bypass ALL Turnkey server-side policies** (root quorum). Production deployments need a non-root API user plus an explicit ALLOW policy; this cannot be detected client-side. +- **Broadcast is never delegated** (Turnkey's managed broadcast is a paid feature) — the default `LocalExecutor` broadcasts over the SDK's own RPC, and the MegaFuel paymaster path works unchanged. +- EIP-712 payloads are built by this module with the `EIP712Domain` entry always included, and EIP-191 messages are hashed locally and blind-signed (Turnkey's policies cannot see message content — content-level control is the SDK-side policy's job). Supported transactions: legacy + EIP-1559 (no access lists). + +Live verification: `TURNKEY_E2E=1 uv run python examples/turnkey_e2e.py` (4 billed signatures; see the script header). + ### `MPCWalletProvider` (stub) Stub-by-design slot for external MPC custody such as Coinbase CDP or Fireblocks: subclass it and implement `address` plus the `sign_*` methods your backend supports - `capabilities()` derives the matching `sign.*` entries from those overrides automatically. The raise-only `sign_*` stubs were removed (an override that only raises would falsely claim the capability); unimplemented methods keep the raising base default. Direct instantiation still raises `NotImplementedError`. diff --git a/python/examples/turnkey_e2e.py b/python/examples/turnkey_e2e.py new file mode 100644 index 0000000..4f2e055 --- /dev/null +++ b/python/examples/turnkey_e2e.py @@ -0,0 +1,192 @@ +"""Live BSC-testnet E2E for the Python Turnkey wallet provider — 4 gated steps. + +⚠️ SPENDS REAL MONEY-SHAPED RESOURCES. Every successful Turnkey signature is +BILLED against the org's quota (free tier: 25 signatures/month at 1 +request/second; pay-as-you-go $0.10/signature). A full run consumes exactly +4 billed signatures plus a few 10⁻⁵ tBNB of gas for two self-transfers. +Calls are strictly serial with a ≥1.1 s gap; the chain-id assertion runs +BEFORE anything billable. + +⚠️ Production posture: run with a NON-ROOT API user restricted by an +explicit ALLOW policy — root API keys bypass ALL Turnkey server-side +policies. The SDK-side SigningPolicy still applies either way. + +Steps (each proves a self-built wire-format piece against the real enclave): + 1. EIP-191 blind digest signing recovers to the Turnkey address. + 2. EIP-712 signing: the self-built full-document payload (EIP712Domain + included) parses in the enclave and binds the REAL domain. + 3. A legacy (gasPrice) self-transfer: the self-built unsigned RLP is + accepted, signs, broadcasts over our own RPC and lands on-chain. + 4. An EIP-1559 self-transfer does the same for the typed encoding. + +Usage (env in ``python/.env`` or the shell, never committed):: + + TURNKEY_E2E=1 + TURNKEY_API_PUBLIC_KEY=... TURNKEY_API_PRIVATE_KEY=... + TURNKEY_ORG_ID=... TURNKEY_SIGN_WITH=0x... + # optional: TURNKEY_API_BASE_URL, RPC_URL + uv run python examples/turnkey_e2e.py + +Requires the turnkey extra (``pip install 'bnbagent[turnkey]'``) and a +little tBNB on the TURNKEY_SIGN_WITH address (~0.001). Exits 0 only when +every step passes. Deliberately NOT part of CI. +""" + +from __future__ import annotations + +import os +import secrets +import sys +import time + +from dotenv import load_dotenv +from eth_account import Account +from eth_account.messages import encode_defunct, encode_typed_data +from web3 import Web3 + +from bnbagent.networks.addresses import BNB_CHAIN_ADDRESSES +from bnbagent.wallets import TurnkeyWalletProvider + +CHAIN_ID = 97 +DEFAULT_RPC = "https://data-seed-prebsc-1-s1.binance.org:8545" +GAP_S = 1.1 # free tier: 1 request/second — stay under it +SIGNATURE_BUDGET = 4 + +_billed = 0 +_last_vendor_call = 0.0 + + +def vendor(label: str, fn): + """Serialize vendor calls (≥1.1 s apart) and count the budget.""" + global _billed, _last_vendor_call + if _billed >= SIGNATURE_BUDGET: + raise RuntimeError(f"signature budget {SIGNATURE_BUDGET} exhausted before {label!r}") + wait = _last_vendor_call + GAP_S - time.monotonic() + if wait > 0: + time.sleep(wait) + _last_vendor_call = time.monotonic() + result = fn() + _billed += 1 + print(f" [budget] {_billed}/{SIGNATURE_BUDGET} billed signatures") + return result + + +def main() -> int: + load_dotenv() + if os.environ.get("TURNKEY_E2E") != "1": + print( + "TURNKEY_E2E != 1 — refusing to run (this script consumes billed " + "Turnkey signatures and testnet gas). Set TURNKEY_E2E=1 plus the " + "TURNKEY_* env vars to opt in." + ) + return 0 + + rpc_url = os.environ.get("RPC_URL") or DEFAULT_RPC + w3 = Web3(Web3.HTTPProvider(rpc_url)) + + # ── Gate 0: chain identity, BEFORE anything billable ──────────────── + chain_id = w3.eth.chain_id + if chain_id != CHAIN_ID: + raise RuntimeError(f"RPC {rpc_url} reports chainId={chain_id}, need {CHAIN_ID}") + + wallet = TurnkeyWalletProvider.from_env(expected_chain_id=CHAIN_ID) + address = wallet.address + balance = w3.eth.get_balance(address) + print( + f"turnkey e2e: signer={address} balance={w3.from_wei(balance, 'ether')} tBNB rpc={rpc_url}" + ) + if balance < 3 * 10**14: + raise RuntimeError( + "signer balance is below the ~0.0003 tBNB needed for two " + f"self-transfers — fund {address} first" + ) + + # ── 1. EIP-191 ─────────────────────────────────────────────────────── + message = f"turnkey-py-e2e {int(time.time())}" + signed = vendor("eip191", lambda: wallet.sign_message(message)) + recovered = Account.recover_message( + encode_defunct(text=message), signature=signed["signature"] + ) + assert recovered == address, f"191 recovered {recovered}, want {address}" + print(f"✅ 1/4 EIP-191 — recovered {recovered}") + + # ── 2. EIP-712 with real-domain binding ───────────────────────────── + payment_token = BNB_CHAIN_ADDRESSES[CHAIN_ID].payment_token + now = int(time.time()) + domain = { + "name": "United Stables", + "version": "1", + "chainId": CHAIN_ID, + "verifyingContract": payment_token, + } + types = { + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + } + message_712 = { + "from": address, + "to": address, + "value": 1, + "validAfter": now - 10, + "validBefore": now + 580, + "nonce": "0x" + secrets.token_hex(32), + } + signed_712 = vendor("eip712", lambda: wallet.sign_typed_data(domain, types, message_712)) + signable = encode_typed_data(domain_data=domain, message_types=types, message_data=message_712) + recovered_712 = Account.recover_message(signable, signature=signed_712["signature"]) + assert recovered_712 == address, ( + f"712 recovered {recovered_712} against the REAL domain, want {address}" + ) + print(f"✅ 2/4 EIP-712 — real-domain recovery {recovered_712}") + + # ── 3+4. legacy and 1559 self-transfers over our own RPC ──────────── + gas_price = w3.eth.gas_price + nonce = w3.eth.get_transaction_count(address, "pending") + transactions = [ + ( + "3/4 legacy tx", + { + "chainId": CHAIN_ID, + "to": address, + "value": 1, + "gas": 21_000, + "nonce": nonce, + "gasPrice": gas_price, + }, + ), + ( + "4/4 eip-1559 tx", + { + "chainId": CHAIN_ID, + "to": address, + "value": 1, + "gas": 21_000, + "nonce": nonce + 1, + "maxFeePerGas": gas_price * 2, + "maxPriorityFeePerGas": gas_price, + }, + ), + ] + for step, tx in transactions: + signed_tx = vendor(step, lambda tx=tx: wallet.sign_transaction(tx)) + tx_hash = w3.eth.send_raw_transaction(signed_tx["rawTransaction"]) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) + assert receipt["status"] == 1, f"{step}: reverted {tx_hash.hex()}" + print(f"✅ {step} — landed 0x{tx_hash.hex().removeprefix('0x')}") + + print(f"\nall 4 steps PASS — {_billed} billed signatures used") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as error: # noqa: BLE001 - top-level reporter + print(f"E2E FAILED: {error}", file=sys.stderr) + sys.exit(1) diff --git a/typescript/README.md b/typescript/README.md index ab94d54..5061275 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -157,15 +157,32 @@ await fundedJobWatcher( Every protocol client signs through the `WalletProvider` seam - swap the provider, keep the protocol code: -| | `EVMWalletProvider` | `TWAKProvider` | `AltanaWalletProvider` | -| --- | --- | --- | --- | -| Custody | local key, Keystore V3 on disk | [twak CLI](../docs/twak.md) keystore (`~/.twak`) + OS keychain; the key never enters this process | EIP-7702 wallet == your EOA; the [Altana capability reference](../docs/altana.md) covers the relay and session model | -| Capabilities | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` | `sign.message`, `broadcast.self`, `intents.erc8004`, `intents.erc8183`, `x402.pay` (no raw signing, no arbitrary calls) | `broadcast.self`, `calls.arbitrary`, `intents.erc8004`, `intents.erc8183` (+ `x402.pay` in session mode) | -| Agent containment | `SigningPolicy` (in-process) | fixed command menu + out-of-process custody; twak's own `--max-payment` hard cap | on-chain session keys: call whitelist + spend caps + expiry, revocable in one tx | -| Gas | self-paid or MegaFuel-sponsored | mainnet auto-sponsored by twak; the SDK forwards its paymaster as `--paymaster-url` (twak >= v0.20.0), so sponsored testnet writes work | relay fronts gas, recovers it from the wallet (MegaFuel not involved) | -| ERC-8183 quote signing | EIP-191 via `walletProvider` | - | ERC-1271 via `sessionQuoteSigner()`; no admin EOA or generic message-signing authority in the agent | -| x402 payments | `X402Signer` | Delegated `TwakX402Payer` (`makeX402Payer()`, five-point quote precheck) | Session-key payer (`makeX402Payer()`, SDK >= 0.4.0) after a one-time admin setup: `approveX402SignatureChecker` + bounded `setPermit2Allowance`; **receiving** at the wallet works fine | -| Extra install | - | `npm i @trustwallet/cli` (>= 0.20.0; the local `node_modules/.bin/twak` is auto-resolved) | `pnpm add @altananetwork/sdk` (optional peer, GPL-3.0-or-later, lazily imported) | +| | `EVMWalletProvider` | `TWAKProvider` | `AltanaWalletProvider` | `TurnkeyWalletProvider` | +| --- | --- | --- | --- | --- | +| Custody | local key, Keystore V3 on disk | [twak CLI](../docs/twak.md) keystore (`~/.twak`) + OS keychain; the key never enters this process | EIP-7702 wallet == your EOA; the [Altana capability reference](../docs/altana.md) covers the relay and session model | key generated + held in [Turnkey](https://docs.turnkey.com)'s AWS Nitro enclave, never leaves it; the agent holds only a local P-256 API key that stamps each request | +| Capabilities | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` | `sign.message`, `broadcast.self`, `intents.erc8004`, `intents.erc8183`, `x402.pay` (no raw signing, no arbitrary calls) | `broadcast.self`, `calls.arbitrary`, `intents.erc8004`, `intents.erc8183` (+ `x402.pay` in session mode) | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` (same surface as EVM, remote signer) | +| Agent containment | `SigningPolicy` (in-process) | fixed command menu + out-of-process custody; twak's own `--max-payment` hard cap | on-chain session keys: call whitelist + spend caps + expiry, revocable in one tx | `SigningPolicy` (in-process, pre-billing) + Turnkey's server-side policy engine — **root API keys bypass the server layer entirely; production needs a non-root API user + explicit ALLOW policy** | +| Gas | self-paid or MegaFuel-sponsored | mainnet auto-sponsored by twak; the SDK forwards its paymaster as `--paymaster-url` (twak >= v0.20.0), so sponsored testnet writes work | relay fronts gas, recovers it from the wallet (MegaFuel not involved) | self-paid or MegaFuel-sponsored (broadcast is always the SDK's own RPC; Turnkey's managed broadcast is a paid feature the provider never uses) | +| ERC-8183 quote signing | EIP-191 via `walletProvider` | - | ERC-1271 via `sessionQuoteSigner()`; no admin EOA or generic message-signing authority in the agent | EIP-191 via `walletProvider` (1 billed signature per quote) | +| x402 payments | `X402Signer` | Delegated `TwakX402Payer` (`makeX402Payer()`, five-point quote precheck) | Session-key payer (`makeX402Payer()`, SDK >= 0.4.0) after a one-time admin setup: `approveX402SignatureChecker` + bounded `setPermit2Allowance`; **receiving** at the wallet works fine | `X402Signer` — works, but $0.10/signature (PAYG) at 1 req/s makes high-frequency buying uneconomical; seller/low-frequency signing is the sweet spot | +| Extra install | - | `npm i @trustwallet/cli` (>= 0.20.0; the local `node_modules/.bin/twak` is auto-resolved) | `pnpm add @altananetwork/sdk` (optional peer, GPL-3.0-or-later, lazily imported) | `pnpm add @turnkey/sdk-server @turnkey/viem` (optional peers, lazily imported) | + +Turnkey quick start (remote enclave signing; every successful signature is billed — free tier 25/month at 1 request/second, pay-as-you-go $0.10/signature): + +```ts +import { TurnkeyWalletProvider } from "@bnbagent/sdk/wallets"; + +// env: TURNKEY_API_PUBLIC_KEY / TURNKEY_API_PRIVATE_KEY / TURNKEY_ORG_ID / +// TURNKEY_SIGN_WITH (the wallet account's 0x ADDRESS, not a wallet id) +const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 }); + +const jobs = await ERC8183Client.create({ + walletProvider: wallet, + network: "bsc-testnet", +}); +``` + +The provider is a pure signer: ERC-8004/8183 writes ride the default `LocalExecutor`, x402 rides `X402Signer`, and the MegaFuel paymaster path works unchanged (enclave-verified with `gasPrice=0` legacy signing). All SDK-side guards — `SigningPolicy`, the `expectedChainId` pin, input validation — run *before* the billable API call, so a refusal never costs quota. One vendor quirk is patched inside `signTypedData`: `@turnkey/viem` <= 0.14.34 silently serializes the EIP-712 domain as `{}` when the `types` object lacks an explicit `EIP712Domain` entry, so the provider always injects it (the signature would otherwise bind an empty domain). Live verification: `TURNKEY_E2E=1 pnpm run e2e:turnkey` (5 billed signatures; see `examples/turnkey/e2e.ts`). Session quick start (admin grants once, agent runs with the session): diff --git a/typescript/examples/turnkey/e2e.ts b/typescript/examples/turnkey/e2e.ts new file mode 100644 index 0000000..fe9cd16 --- /dev/null +++ b/typescript/examples/turnkey/e2e.ts @@ -0,0 +1,241 @@ +/** + * Live BSC-testnet E2E for the Turnkey wallet provider — 5 gated steps. + * + * ⚠️ SPENDS REAL MONEY-SHAPED RESOURCES. Every successful Turnkey signature + * is BILLED against the org's quota (free tier: 25 signatures/month at + * 1 request/second; pay-as-you-go $0.10/signature). A full run consumes + * exactly 5 billed signatures plus a few 10⁻⁵ tBNB of gas for two + * self-transfers. Calls are strictly serial with a ≥1.1 s gap (free-tier + * rate limit); the chain-id assertion runs BEFORE anything billable. + * + * ⚠️ Production posture reminder: run this with a NON-ROOT API user + * restricted by an explicit ALLOW policy — a root user's API key bypasses + * ALL Turnkey server-side policies (root quorum). The SDK-side + * SigningPolicy still applies either way. + * + * What the steps prove (mapping to the 2026-07-24 probe findings): + * 1. EIP-191 blind digest signing recovers to the Turnkey address. + * 2. EIP-712 signing binds the REAL domain — the live regression check + * for the `@turnkey/viem` ≤0.14.34 EIP712Domain-stripping trap the + * provider patches (an empty-domain signature would fail the + * recoverTypedDataAddress comparison here). + * 3. A legacy (gasPrice) self-transfer signs, broadcasts over our own + * RPC (managed broadcast is paywalled — never used) and lands. + * 4. An EIP-1559 self-transfer does the same. + * 5. `X402Signer.signPayment` works against the provider unchanged — + * the "implement signTypedData and x402 comes free" contract. + * ERC-8004 registration through the full ContractInterface→LocalExecutor + * pipeline was probe-verified on-chain (agentId=1727) and is not repeated + * here to protect the signature budget. + * + * Usage (env in `typescript/.env`, never committed): + * TURNKEY_E2E=1 + * TURNKEY_API_PUBLIC_KEY=... TURNKEY_API_PRIVATE_KEY=... + * TURNKEY_ORG_ID=... TURNKEY_SIGN_WITH=0x... + * # optional: TURNKEY_API_BASE_URL, RPC_URL + * pnpm -C typescript run e2e:turnkey + * + * The TURNKEY_SIGN_WITH address needs a little tBNB for gas (~0.001). + * Exits 0 only when every step PASSes; any FAIL aborts and exits 1. + * Deliberately NOT part of CI. + */ + +import { + http, + createPublicClient, + formatEther, + hashMessage, + recoverMessageAddress, + recoverTypedDataAddress, +} from "viem"; +import { NETWORKS } from "../../src/config.js"; +import { loadEnv } from "../../src/core/env.js"; +import { getEnv } from "../../src/core/envUtil.js"; +import { BNB_CHAIN_ADDRESSES } from "../../src/networks/addresses.js"; +import { TurnkeyWalletProvider } from "../../src/wallets/turnkey/provider.js"; +import { X402Signer } from "../../src/x402/signer.js"; + +const CHAIN_ID = 97; +const GAP_MS = 1100; // free tier: 1 request/second — stay under it +const SIGNATURE_BUDGET = 5; + +let billedSignatures = 0; +let lastVendorCallAt = 0; + +/** Serialize vendor calls (≥1.1 s apart) and count the signature budget. */ +async function vendor(label: string, fn: () => Promise): Promise { + if (billedSignatures >= SIGNATURE_BUDGET) { + throw new Error( + `signature budget of ${SIGNATURE_BUDGET} exhausted before '${label}'`, + ); + } + const wait = lastVendorCallAt + GAP_MS - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + lastVendorCallAt = Date.now(); + const result = await fn(); + billedSignatures += 1; + console.log(` [budget] ${billedSignatures}/${SIGNATURE_BUDGET} billed signatures`); + return result; +} + +function pass(step: string, detail: string): void { + console.log(`✅ ${step} — ${detail}`); +} + +async function main(): Promise { + loadEnv(); + if (getEnv("TURNKEY_E2E") !== "1") { + console.log( + "TURNKEY_E2E != 1 — refusing to run (this script consumes billed Turnkey signatures and testnet gas). Set TURNKEY_E2E=1 plus the TURNKEY_* env vars in typescript/.env to opt in.", + ); + return; + } + + const network = NETWORKS["bsc-testnet"]; + if (!network) throw new Error("bsc-testnet preset missing"); + const paymentToken = BNB_CHAIN_ADDRESSES[CHAIN_ID]?.paymentToken; + if (!paymentToken) throw new Error(`no payment token registered for chain ${CHAIN_ID}`); + const rpcUrl = getEnv("RPC_URL") ?? network.rpcUrl; + const client = createPublicClient({ transport: http(rpcUrl) }); + + // ── Gate 0: chain identity, BEFORE anything billable ──────────────── + const chainId = await client.getChainId(); + if (chainId !== CHAIN_ID) { + throw new Error(`RPC ${rpcUrl} reports chainId=${chainId}, need ${CHAIN_ID}`); + } + + const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: CHAIN_ID }); + const address = wallet.address; + const balance = await client.getBalance({ address }); + console.log( + `turnkey e2e: signer=${address} balance=${formatEther(balance)} tBNB rpc=${rpcUrl}`, + ); + if (balance < 300_000_000_000_000n) { + throw new Error( + `signer balance ${formatEther(balance)} tBNB is below the ~0.0003 needed for two self-transfers — fund ${address} first`, + ); + } + + // ── 1. EIP-191 ────────────────────────────────────────────────────── + const message = `turnkey-e2e ${new Date().toISOString()}`; + const signed191 = await vendor("eip191", () => wallet.signMessage(message)); + if (signed191.messageHash !== hashMessage(message)) { + throw new Error("191 digest mismatch"); + } + const recovered191 = await recoverMessageAddress({ + message, + signature: signed191.signature, + }); + if (recovered191 !== address) { + throw new Error(`191 recovered ${recovered191}, want ${address}`); + } + pass("1/5 EIP-191", `recovered ${recovered191}`); + + // ── 2. EIP-712 with domain binding (the stripping-trap live check) ── + const nowSec = Math.floor(Date.now() / 1000); + const domain = { + name: "United Stables", + version: "1", + chainId: CHAIN_ID, + verifyingContract: paymentToken, + }; + const types = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }; + const message712 = { + from: address, + to: address, + value: 1n, + validAfter: BigInt(nowSec - 10), + validBefore: BigInt(nowSec + 580), + nonce: `0x${crypto.getRandomValues(new Uint8Array(32)).reduce((acc, b) => acc + b.toString(16).padStart(2, "0"), "")}` as `0x${string}`, + }; + const signed712 = await vendor("eip712", () => + wallet.signTypedData(domain, types, message712), + ); + const recovered712 = await recoverTypedDataAddress({ + domain, + types, + primaryType: "TransferWithAuthorization", + message: message712, + signature: signed712.signature, + }); + if (recovered712 !== address) { + throw new Error( + `712 recovered ${recovered712} against the REAL domain, want ${address} — empty-domain binding (the 0.14.x trap) would fail exactly here`, + ); + } + pass("2/5 EIP-712", `real-domain recovery ${recovered712}`); + + // ── 3+4. legacy and 1559 self-transfers over our own RPC ──────────── + const gasPrice = await client.getGasPrice(); + let nonce = await client.getTransactionCount({ address, blockTag: "pending" }); + + for (const [step, tx] of [ + [ + "3/5 legacy tx", + { chainId: CHAIN_ID, to: address, value: 1n, gas: 21_000n, nonce: nonce++, gasPrice }, + ], + [ + "4/5 eip-1559 tx", + { + chainId: CHAIN_ID, + to: address, + value: 1n, + gas: 21_000n, + nonce: nonce++, + maxFeePerGas: gasPrice * 2n, + maxPriorityFeePerGas: gasPrice, + }, + ], + ] as const) { + const signedTx = await vendor(step, () => + wallet.signTransaction(tx as Parameters[0]), + ); + const hash = await client.sendRawTransaction({ + serializedTransaction: signedTx.rawTransaction, + }); + const receipt = await client.waitForTransactionReceipt({ hash, timeout: 120_000 }); + if (receipt.status !== "success") throw new Error(`${step}: reverted ${hash}`); + pass(step, `landed ${hash}`); + } + + // ── 5. x402 free-ride through X402Signer ──────────────────────────── + const signer = new X402Signer(wallet, { + maxValuePerCall: { [paymentToken]: 10n }, + sessionBudget: { [paymentToken]: 10n }, + }); + const payment = await vendor("x402 signPayment", () => + signer.signPayment({ + domain: domain as Record, + types, + message: { ...message712, value: 10n }, + expectedTo: address, + }), + ); + const recoveredPay = await recoverTypedDataAddress({ + domain, + types, + primaryType: "TransferWithAuthorization", + message: { ...message712, value: 10n }, + signature: payment.signature as `0x${string}`, + }); + if (recoveredPay !== address) { + throw new Error(`x402 payment recovered ${recoveredPay}, want ${address}`); + } + pass("5/5 X402Signer", "payment signature recovers; policy + budget gates passed"); + + console.log(`\nall 5 steps PASS — ${billedSignatures} billed signatures used`); +} + +main().catch((error) => { + console.error("E2E FAILED:", error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/typescript/package.json b/typescript/package.json index d275c44..66c4eb8 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@bnbagent/sdk", - "version": "0.5.0", + "version": "0.5.1", "description": "TypeScript SDK for building on-chain AI agents on BNB Chain (ERC-8004 identity, ERC-8183 agentic commerce, x402 payments).", "license": "MIT", "repository": { From 73dadfc714f99245ec52e5561e16529baef01aef Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:00:44 +0800 Subject: [PATCH 6/7] chore(python): correct version to 0.4.3 (features ride patch in 0.x) --- python/bnbagent/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/bnbagent/_version.py b/python/bnbagent/_version.py index 79c117c..ea37fa4 100644 --- a/python/bnbagent/_version.py +++ b/python/bnbagent/_version.py @@ -3,4 +3,4 @@ Hatchling reads this file via [tool.hatch.version] in pyproject.toml, so changing the literal below is the only thing needed to bump versions. """ -__version__ = "0.5.0" +__version__ = "0.4.3" From cd8286ded84e2138cd55b7e1e353546474f37fac Mon Sep 17 00:00:00 2001 From: jardenx <274764117+jardenx@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:51:19 +0800 Subject: [PATCH 7/7] fix: harden Turnkey wallet and clean Python lint --- python/bnbagent/__init__.py | 34 +-- python/bnbagent/core/contract_mixin.py | 22 +- python/bnbagent/core/multicall.py | 6 +- python/bnbagent/erc8004/agent.py | 2 +- python/bnbagent/erc8004/constants.py | 3 +- python/bnbagent/erc8004/models.py | 9 +- python/bnbagent/erc8183/__init__.py | 2 +- python/bnbagent/erc8183/client.py | 27 +- python/bnbagent/erc8183/config.py | 3 +- python/bnbagent/erc8183/job_ops.py | 78 +++--- python/bnbagent/erc8183/negotiation.py | 21 +- python/bnbagent/erc8183/policy.py | 14 +- python/bnbagent/erc8183/schema.py | 16 +- python/bnbagent/networks/__init__.py | 2 +- python/bnbagent/signing/policy.py | 81 +++--- python/bnbagent/storage/__init__.py | 2 +- .../bnbagent/wallets/evm_wallet_provider.py | 13 +- python/bnbagent/wallets/turnkey/client.py | 6 +- python/bnbagent/wallets/turnkey/provider.py | 3 +- python/bnbagent/wallets/twak_provider.py | 131 +++++---- python/bnbagent/wallets/wallet_provider.py | 28 +- python/bnbagent/x402/signer.py | 10 +- python/examples/a2a-agent/scripts/buyer.py | 14 +- python/examples/a2a-agent/scripts/register.py | 6 +- python/examples/a2a-agent/src/server.py | 25 +- .../examples/agent-server/scripts/register.py | 22 +- .../agent-server/scripts/run_agent.py | 2 +- .../agent-server/scripts/run_agent_mount.py | 3 +- .../agent-server/src/erc8183_server.py | 33 +-- python/examples/agent-server/src/service.py | 27 +- .../agent-server/src/service_mount.py | 20 +- .../agent-server/tests/test_routes_poll.py | 43 ++- .../agent-server/tests/test_routes_startup.py | 12 +- python/examples/auto_settle.py | 15 +- python/examples/client/_helpers.py | 8 +- python/examples/client/create_and_verify.py | 13 +- python/examples/client/dispute_reject.py | 12 +- python/examples/client/gen_job_report.py | 180 +++++++----- python/examples/client/happy.py | 7 +- python/examples/client/stalemate_expire.py | 12 +- python/examples/client/zero_price.py | 8 +- python/examples/security/e2e.py | 26 +- python/examples/twak/quickstart.py | 32 +-- python/examples/voter/vote_reject.py | 15 +- python/examples/voter/watch.py | 40 +-- python/examples/x402/buyer_demo.py | 25 +- python/tests/test_erc8004_contract.py | 85 ++++-- python/tests/test_erc8183_client.py | 55 ++-- python/tests/test_erc8183_job_ops.py | 34 +-- python/tests/test_erc8183_policy.py | 8 +- python/tests/test_erc8183_router.py | 17 +- python/tests/test_erc8183_schema.py | 5 +- python/tests/test_ipfs_storage.py | 32 ++- python/tests/test_local_storage.py | 4 +- python/tests/test_multicall.py | 8 +- python/tests/test_negotiation.py | 95 ++++--- python/tests/test_networks_addresses.py | 11 +- python/tests/test_signing_policy.py | 47 ++-- python/tests/test_storage_from_env.py | 2 +- python/tests/test_turnkey_client.py | 158 +++++++++++ python/tests/test_turnkey_provider.py | 21 +- python/tests/test_twak_provider.py | 265 ++++++++++++------ python/tests/test_wallet.py | 20 +- typescript/src/wallets/turnkey/provider.ts | 5 +- typescript/src/wallets/walletProvider.ts | 7 +- typescript/tests/turnkeyProvider.test.ts | 4 +- 66 files changed, 1266 insertions(+), 730 deletions(-) create mode 100644 python/tests/test_turnkey_client.py diff --git a/python/bnbagent/__init__.py b/python/bnbagent/__init__.py index f9d503a..f93154e 100644 --- a/python/bnbagent/__init__.py +++ b/python/bnbagent/__init__.py @@ -26,15 +26,28 @@ from __future__ import annotations -# ERC-8183 — only essential public API -from .erc8183 import ERC8183Client, JobStatus, Verdict +from ._version import __version__ # Configuration from .config import NetworkConfig +# Transaction tuning (gas-price floor + receipt timeout) — public knobs that +# replace any downstream monkey-patching of SDK internals. +from .core.contract_mixin import ( + get_default_receipt_timeout, + set_default_receipt_timeout, + set_min_gas_price_wei, +) + +# Opt-in .env loading (never called at import time — applications opt in) +from .core.env import load_env + # ERC-8004 Identity Registry from .erc8004 import AgentEndpoint, ERC8004Agent +# ERC-8183 — only essential public API +from .erc8183 import ERC8183Client, JobStatus, Verdict + # Exceptions from .exceptions import ( BNBAgentError, @@ -42,29 +55,18 @@ TransactionPendingError, ) -# Opt-in .env loading (never called at import time — applications opt in) -from .core.env import load_env - -# Transaction tuning (gas-price floor + receipt timeout) — public knobs that -# replace any downstream monkey-patching of SDK internals. -from .core.contract_mixin import ( - get_default_receipt_timeout, - set_default_receipt_timeout, - set_min_gas_price_wei, -) +# Signing policy +from .signing import PolicyViolation, SigningPolicy # Wallets from .wallets import EVMWalletProvider, WalletProvider -# Signing policy -from .signing import PolicyViolation, SigningPolicy - # x402 payment signer from .x402 import X402Signer -from ._version import __version__ __all__ = [ # Core + "__version__", "NetworkConfig", "BNBAgentError", "TransactionPendingError", diff --git a/python/bnbagent/core/contract_mixin.py b/python/bnbagent/core/contract_mixin.py index 5265ccc..9045839 100644 --- a/python/bnbagent/core/contract_mixin.py +++ b/python/bnbagent/core/contract_mixin.py @@ -145,9 +145,7 @@ def _execute_intent(self, intent) -> dict[str, Any]: # how to use it (the local executor sponsors when sponsorable and # self-pays otherwise; a self-broadcasting wallet ignores it). executor = self._wallet_provider.make_executor( - ExecutionContext( - web3=self.w3, paymaster=getattr(self, "_paymaster", None) - ) + ExecutionContext(web3=self.w3, paymaster=getattr(self, "_paymaster", None)) ) self._intent_executor = executor return executor.execute(intent) @@ -203,6 +201,7 @@ def _send_tx( # Skipped when skip_preflight=True (e.g. when node returns opaque 0x reverts). if not skip_preflight: import concurrent.futures as _cf + _call_params = { "from": self._account, "to": tx.get("to"), @@ -215,23 +214,28 @@ def _send_tx( try: _future.result(timeout=10) except _cf.TimeoutError: - logger.warning(f"[{class_name}] Pre-flight eth_call timed out, proceeding anyway") + logger.warning( + f"[{class_name}] Pre-flight eth_call timed out, proceeding anyway" + ) except Exception as preflight_err: err_str = str(preflight_err) # Skip pre-flight if node returns opaque 0x (no revert data) if "'0x'" in err_str or err_str.strip().endswith(", '0x')"): - logger.warning(f"[{class_name}] Pre-flight returned opaque 0x revert, proceeding to on-chain tx") + logger.warning( + f"[{class_name}] Pre-flight returned opaque 0x revert, " + "proceeding to on-chain tx" + ) else: - raise RuntimeError(f"Transaction would revert: {preflight_err}") from preflight_err + raise RuntimeError( + f"Transaction would revert: {preflight_err}" + ) from preflight_err signed = self._wallet_provider.sign_transaction(tx) raw_tx = signed["rawTransaction"] tx_hash = self.w3.eth.send_raw_transaction(raw_tx) timeout = get_default_receipt_timeout() try: - receipt = self.w3.eth.wait_for_transaction_receipt( - tx_hash, timeout=timeout - ) + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) except TimeExhausted as exc: # Broadcast OK (nonce consumed) but unconfirmed in time — # surface as pending with the hash, never as a fatal/retry diff --git a/python/bnbagent/core/multicall.py b/python/bnbagent/core/multicall.py index 8b97eb0..3c0305d 100644 --- a/python/bnbagent/core/multicall.py +++ b/python/bnbagent/core/multicall.py @@ -58,6 +58,7 @@ def _get_output_types(contract: Contract, function_name: str) -> list[str]: return [_abi_type(o) for o in item.get("outputs", [])] raise ValueError(f"Function {function_name} not found in ABI") + # Canonical Multicall3 address — same on all EVM chains MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" @@ -152,7 +153,7 @@ def multicall_read( batch = encoded_calls[i : i + batch_size] raw_results = _aggregate3_with_retry(multicall3, batch) - for j, (success, return_data) in enumerate(raw_results): + for success, return_data in raw_results: if success and return_data: try: decoded = abi_decode(output_types, return_data) @@ -181,8 +182,7 @@ def _aggregate3_with_retry(multicall3: Contract, calls: list[dict]) -> list[tupl if is_rate_limit and attempt < MAX_RETRIES - 1: delay = RETRY_BASE_DELAY * (2**attempt) logger.warning( - f"[Multicall3] Rate limited, retry {attempt + 1}/{MAX_RETRIES} " - f"in {delay:.1f}s" + f"[Multicall3] Rate limited, retry {attempt + 1}/{MAX_RETRIES} in {delay:.1f}s" ) time.sleep(delay) else: diff --git a/python/bnbagent/erc8004/agent.py b/python/bnbagent/erc8004/agent.py index a168cec..4f23536 100644 --- a/python/bnbagent/erc8004/agent.py +++ b/python/bnbagent/erc8004/agent.py @@ -22,8 +22,8 @@ from ..constants import SCAN_API_URL from ..core.paymaster import Paymaster from ..exceptions import ERC8004PartialRegistrationError, TransactionPendingError -from .agent_uri import AgentURIGenerator from ..wallets import WalletProvider +from .agent_uri import AgentURIGenerator from .constants import get_erc8004_config from .contract import ContractInterface from .models import AgentEndpoint diff --git a/python/bnbagent/erc8004/constants.py b/python/bnbagent/erc8004/constants.py index 705b23a..199afe5 100644 --- a/python/bnbagent/erc8004/constants.py +++ b/python/bnbagent/erc8004/constants.py @@ -8,6 +8,7 @@ from typing import Any +from .._version import __version__ as _sdk_version from ..config import resolve_network from ..core.config import get_env @@ -33,8 +34,6 @@ def get_erc8004_config(network: str = "bsc-testnet") -> dict[str, Any]: } -from .._version import __version__ as _sdk_version - BUILT_WITH_KEY = "built_with" _BUILT_WITH_URL = "https://github.com/bnb-chain/bnbagent-sdk" diff --git a/python/bnbagent/erc8004/models.py b/python/bnbagent/erc8004/models.py index e42520c..3d1bb8e 100644 --- a/python/bnbagent/erc8004/models.py +++ b/python/bnbagent/erc8004/models.py @@ -108,8 +108,9 @@ def a2a( invalid URL — the path is inserted before the query/fragment instead. Example: - >>> AgentEndpoint.a2a("https://agent.example") - AgentEndpoint(name='A2A', endpoint='https://agent.example/.well-known/agent-card.json', ...) + >>> endpoint = AgentEndpoint.a2a("https://agent.example") + >>> endpoint.endpoint + 'https://agent.example/.well-known/agent-card.json' """ return cls( name="A2A", @@ -137,9 +138,7 @@ def _append_agent_card_path(cls, base_url: str) -> str: path = parts.path.rstrip("/") if not path.endswith(cls.A2A_WELL_KNOWN_PATH): path += cls.A2A_WELL_KNOWN_PATH - return urlunsplit( - (parts.scheme, parts.netloc, path, parts.query, parts.fragment) - ) + return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)) @classmethod def mcp( diff --git a/python/bnbagent/erc8183/__init__.py b/python/bnbagent/erc8183/__init__.py index 7990bcc..a5c732c 100644 --- a/python/bnbagent/erc8183/__init__.py +++ b/python/bnbagent/erc8183/__init__.py @@ -16,6 +16,7 @@ from .client import DEFAULT_APPROVE_FLOOR_UNITS, ERC8183Client from .commerce import CommerceClient from .constants import get_erc8183_config +from .job_ops import ERC8183JobOps, funded_job_watcher from .negotiation import ( NegotiationHandler, NegotiationRequest, @@ -26,7 +27,6 @@ ) from .policy import PolicyClient from .router import RouterClient -from .job_ops import ERC8183JobOps, funded_job_watcher from .schema import SCHEMA_VERSION, DeliverableManifest, JobDescription from .types import ( REASON_APPROVED, diff --git a/python/bnbagent/erc8183/client.py b/python/bnbagent/erc8183/client.py index 2bdc77d..e598280 100644 --- a/python/bnbagent/erc8183/client.py +++ b/python/bnbagent/erc8183/client.py @@ -34,8 +34,8 @@ from ..config import NetworkConfig, resolve_network from ..core.abi_loader import create_web3 -from ..wallets.wallet_provider import WalletProvider from ..erc20.client import MinimalERC20Client +from ..wallets.wallet_provider import WalletProvider from .commerce import CommerceClient from .policy import PolicyClient from .router import RouterClient @@ -110,9 +110,7 @@ def __init__( ) self._wallet_provider = wallet_provider - self.address: str | None = ( - wallet_provider.address if wallet_provider is not None else None - ) + self.address: str | None = wallet_provider.address if wallet_provider is not None else None # Gas sponsorship: wire a paymaster into the write path only on # networks where MegaFuel sponsors ERC-8183 (testnet today; mainnet @@ -149,11 +147,7 @@ def _build_paymaster(nc: NetworkConfig, debug: bool): Note: the ERC-20 ``approve`` inside :meth:`fund` runs through the ERC-20 client's own self-pay path and is not sponsored here. """ - if ( - nc.use_paymaster - and nc.paymaster_url - and nc.chain_id in ERC8183_PAYMASTER_CHAIN_IDS - ): + if nc.use_paymaster and nc.paymaster_url and nc.chain_id in ERC8183_PAYMASTER_CHAIN_IDS: from ..core.paymaster import Paymaster return Paymaster(paymaster_url=nc.paymaster_url, debug=debug) @@ -170,9 +164,7 @@ def payment_token(self) -> str: def _erc20_client(self) -> MinimalERC20Client: if self._erc20 is None: - self._erc20 = MinimalERC20Client( - self.w3, self.payment_token, self._wallet_provider - ) + self._erc20 = MinimalERC20Client(self.w3, self.payment_token, self._wallet_provider) return self._erc20 def token_decimals(self) -> int: @@ -224,13 +216,14 @@ def create_job( if not skip_expiry_check: try: import time + dispute_window = int(self.policy.dispute_window()) now = int(time.time()) if expired_at - now <= dispute_window: raise ValueError( f"expired_at ({expired_at}) is too close to now ({now}). " f"OptimisticPolicy on this network has dispute_window=" - f"{dispute_window}s ({dispute_window/86400:.1f}d), so the " + f"{dispute_window}s ({dispute_window / 86400:.1f}d), so the " f"submit deadline (expired_at - dispute_window = " f"{expired_at - dispute_window}) is already in the past or " f"within seconds. provider.submit() would revert with " @@ -312,7 +305,9 @@ def fund( cap = max(amount, floor) logger.debug( "[ERC8183Client] topping up allowance: current=%s amount=%s cap=%s", - current, amount, cap, + current, + amount, + cap, ) self.approve_payment_token(self.commerce.address, cap) @@ -394,7 +389,9 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) -> hint_block = self._resolve_submit_block(job_id) return self.policy.get_deliverable_url(job_id, hint_block=hint_block) - def _resolve_submit_block(self, job_id: int, *, lookback: int = 50_000, step: int = 1_000) -> int | None: + def _resolve_submit_block( + self, job_id: int, *, lookback: int = 50_000, step: int = 1_000 + ) -> int | None: """Find the block where ``JobSubmitted`` was emitted for *job_id*. Walks backwards from the current head in ``step``-block windows so diff --git a/python/bnbagent/erc8183/config.py b/python/bnbagent/erc8183/config.py index 782da76..daf4a33 100644 --- a/python/bnbagent/erc8183/config.py +++ b/python/bnbagent/erc8183/config.py @@ -132,7 +132,7 @@ def effective_policy_address(self) -> str: @classmethod def from_env( cls, - storage: "StorageProvider | None" = None, + storage: StorageProvider | None = None, ) -> ERC8183Config: """Load ERC-8183 configuration from the environment. @@ -180,6 +180,7 @@ def from_env( if storage is None: from ..storage import LocalStorageProvider + storage = LocalStorageProvider.from_env() return cls( diff --git a/python/bnbagent/erc8183/job_ops.py b/python/bnbagent/erc8183/job_ops.py index 7944db3..610da34 100644 --- a/python/bnbagent/erc8183/job_ops.py +++ b/python/bnbagent/erc8183/job_ops.py @@ -38,7 +38,7 @@ _DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB -_DEFAULT_MAX_METADATA_BYTES = 256 * 1024 # 256 KB +_DEFAULT_MAX_METADATA_BYTES = 256 * 1024 # 256 KB def _read_int_env(key: str, default: int) -> int: @@ -53,7 +53,10 @@ def _read_int_env(key: str, default: int) -> int: except ValueError: logger.warning( "[ERC8183JobOps] %s%s=%r invalid, using default %d", - ERC8183_ENV_PREFIX, key, raw, default, + ERC8183_ENV_PREFIX, + key, + raw, + default, ) return default @@ -67,8 +70,14 @@ def _max_metadata_bytes() -> int: _TRANSIENT_ERROR_KEYWORDS = ( - "timeout", "connection", "network", "rpc", - "429", "too many requests", "rate limit", "limit exceeded", + "timeout", + "connection", + "network", + "rpc", + "429", + "too many requests", + "rate limit", + "limit exceeded", ) # ── Semantic error codes (transport-neutral) ── @@ -80,17 +89,17 @@ def _max_metadata_bytes() -> int: # Retry contract: error dicts carry ``"retryable": True`` only for transient # failures (``chain_unavailable``, ``internal_error``); absence means the # failure is permanent and retrying cannot succeed. -ERR_BUDGET_TOO_LOW = "budget_too_low" # budget < service_price -ERR_NOT_ASSIGNED = "not_assigned" # job.provider != this agent -ERR_NOT_FOUND = "not_found" # job / stored response missing -ERR_JOB_EXPIRED = "job_expired" # past job.expiredAt -ERR_WRONG_STATUS = "wrong_status" # job not in the required status +ERR_BUDGET_TOO_LOW = "budget_too_low" # budget < service_price +ERR_NOT_ASSIGNED = "not_assigned" # job.provider != this agent +ERR_NOT_FOUND = "not_found" # job / stored response missing +ERR_JOB_EXPIRED = "job_expired" # past job.expiredAt +ERR_WRONG_STATUS = "wrong_status" # job not in the required status ERR_DESCRIPTION_INVALID = "description_invalid" # malformed on-chain description (fail closed) ERR_SUBMIT_DEADLINE_PASSED = "submit_deadline_passed" # past expiredAt - disputeWindow ERR_PAYLOAD_TOO_LARGE = "payload_too_large" # response/metadata size cap hit -ERR_INTERNAL = "internal_error" # unexpected failure (retryable) +ERR_INTERNAL = "internal_error" # unexpected failure (retryable) ERR_CHAIN_UNAVAILABLE = "chain_unavailable" # transient chain/RPC trouble (retryable) -ERR_TX_PENDING = "tx_pending" # tx broadcast but unconfirmed (NOT retryable) +ERR_TX_PENDING = "tx_pending" # tx broadcast but unconfirmed (NOT retryable) def _exc_error_fields(exc: Exception) -> dict[str, Any]: @@ -262,23 +271,19 @@ async def submit_result( return { "success": False, "error": ( - f"response_content size {actual_resp} bytes exceeds " - f"limit {max_resp} bytes" + f"response_content size {actual_resp} bytes exceeds limit {max_resp} bytes" ), "error_code": ERR_PAYLOAD_TOO_LARGE, } if metadata is not None: max_meta = _max_metadata_bytes() - actual_meta = len( - json.dumps(metadata, separators=(",", ":")).encode("utf-8") - ) + actual_meta = len(json.dumps(metadata, separators=(",", ":")).encode("utf-8")) if actual_meta > max_meta: return { "success": False, "error": ( - f"metadata size {actual_meta} bytes exceeds " - f"limit {max_meta} bytes" + f"metadata size {actual_meta} bytes exceeds limit {max_meta} bytes" ), "error_code": ERR_PAYLOAD_TOO_LARGE, } @@ -349,10 +354,14 @@ async def get_job(self, job_id: int) -> dict[str, Any]: # Return a generic message — the raw exception can embed the RPC # URL (and its API key) on transport errors. Classify here so # callers still get the right status without parsing the message. - is_net = any(k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc")) + is_net = any( + k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc") + ) return { "success": False, - "error": "Temporary chain/RPC error" if is_net else "Failed to fetch job from chain", + "error": "Temporary chain/RPC error" + if is_net + else "Failed to fetch job from chain", "error_code": ERR_CHAIN_UNAVAILABLE if is_net else ERR_INTERNAL, "retryable": True, } @@ -387,9 +396,7 @@ async def get_response(self, job_id: int) -> dict[str, Any]: try: erc8183 = self._get_client() - deliverable_url = await asyncio.to_thread( - erc8183.get_deliverable_url, job_id - ) + deliverable_url = await asyncio.to_thread(erc8183.get_deliverable_url, job_id) if deliverable_url: self._deliverable_urls[job_id] = deliverable_url data = await self._storage.download(deliverable_url) @@ -406,7 +413,9 @@ async def get_response(self, job_id: int) -> dict[str, Any]: "retryable": True, } except Exception as exc: - logger.warning(f"[ERC8183JobOps] get_response({job_id}) on-chain fallback failed: {exc}") + logger.warning( + f"[ERC8183JobOps] get_response({job_id}) on-chain fallback failed: {exc}" + ) # A job that has been submitted on-chain MUST have a JobInitialised # event, so failing to resolve its URL above (rate-limited RPC, @@ -475,9 +484,7 @@ async def verify_job(self, job_id: int) -> dict[str, Any]: # agent doesn't keep retrying every funded-poll tick on a job whose # submit deadline has already passed. try: - dispute_window = await asyncio.to_thread( - self._get_client().policy.dispute_window - ) + dispute_window = await asyncio.to_thread(self._get_client().policy.dispute_window) submit_deadline = expired_at - int(dispute_window) if now > submit_deadline: return { @@ -550,7 +557,9 @@ async def verify_job(self, job_id: int) -> dict[str, Any]: } except Exception as exc: logger.error(f"[ERC8183JobOps] verify_job({job_id}) failed: {exc}") - is_net = any(k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc")) + is_net = any( + k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc") + ) return { "valid": False, "error": "Temporary chain/RPC error" if is_net else "Failed to verify job", @@ -727,7 +736,8 @@ async def _fire(job: dict[str, Any]) -> None: except Exception as exc: logger.error( "[funded_job_watcher] on_funded(%s) failed; will retry: %s", - job_id, exc, + job_id, + exc, ) retry.add(job_id) return @@ -745,9 +755,8 @@ async def _fire(job: dict[str, Any]) -> None: fresh = await job_ops.get_job(job_id) if not fresh.get("success"): continue # transient read error — keep for next tick - if ( - fresh.get("status") != JobStatus.FUNDED - or fresh.get("expiredAt", 0) <= int(time.time()) + if fresh.get("status") != JobStatus.FUNDED or fresh.get("expiredAt", 0) <= int( + time.time() ): retry.discard(job_id) # job moved on — stop retrying continue @@ -760,9 +769,7 @@ async def _fire(job: dict[str, Any]) -> None: continue await _fire(job) else: - logger.warning( - "[funded_job_watcher] poll error: %s", result.get("error") - ) + logger.warning("[funded_job_watcher] poll error: %s", result.get("error")) except Exception as exc: logger.error("[funded_job_watcher] iteration failed: %s", exc) @@ -774,4 +781,3 @@ async def _fire(job: dict[str, Any]) -> None: continue else: await asyncio.sleep(interval) - diff --git a/python/bnbagent/erc8183/negotiation.py b/python/bnbagent/erc8183/negotiation.py index 6a94928..ce7cd8a 100644 --- a/python/bnbagent/erc8183/negotiation.py +++ b/python/bnbagent/erc8183/negotiation.py @@ -38,7 +38,7 @@ import json import logging import time -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING logger = logging.getLogger(__name__) @@ -58,8 +58,9 @@ class DescriptionTooLongError(ValueError): if TYPE_CHECKING: - from .client import ERC8183Client from ..wallets.protocols import MessageSigner + from .client import ERC8183Client + from .schema import JobDescription class ReasonCode: @@ -390,8 +391,14 @@ def _build_description_content( if success_criteria: terms["success_criteria"] = [_sanitize_for_claim(c) for c in success_criteria] - negotiated_at = negotiation_result.get("negotiated_at") or response.get("negotiated_at") or int(time.time()) - quote_expires_at = negotiation_result.get("quote_expires_at") or response.get("quote_expires_at") + negotiated_at = ( + negotiation_result.get("negotiated_at") + or response.get("negotiated_at") + or int(time.time()) + ) + quote_expires_at = negotiation_result.get("quote_expires_at") or response.get( + "quote_expires_at" + ) content: dict = { "version": 1, @@ -471,7 +478,7 @@ def build_job_description( return description -def parse_job_description(description: str) -> "JobDescription | None": +def parse_job_description(description: str) -> JobDescription | None: """Parse a structured on-chain job description (schema v1+). Returns a ``JobDescription`` if the description is a valid structured JSON, @@ -481,6 +488,7 @@ def parse_job_description(description: str) -> "JobDescription | None": description: The job.description string from on-chain. """ from .schema import JobDescription + return JobDescription.from_str(description) @@ -531,7 +539,8 @@ def __init__( Initialize the negotiation handler. Args: - service_price: Price in token smallest unit (e.g., "20000000000000000000" for 20 tokens) + service_price: Price in token smallest unit + (e.g., "20000000000000000000" for 20 tokens) currency: BEP20 token contract address estimated_completion_seconds: Estimated time to complete the service require_quality_standards: Whether to require quality_standards in request diff --git a/python/bnbagent/erc8183/policy.py b/python/bnbagent/erc8183/policy.py index ff2b4a3..b61f7eb 100644 --- a/python/bnbagent/erc8183/policy.py +++ b/python/bnbagent/erc8183/policy.py @@ -17,8 +17,6 @@ import logging from typing import Any -logger = logging.getLogger(__name__) - from web3 import Web3 from web3.contract import Contract @@ -29,6 +27,8 @@ from ..wallets.wallet_provider import WalletProvider from .types import Verdict +logger = logging.getLogger(__name__) + def _load_abi() -> list: return load_abi("OptimisticPolicy.json") @@ -125,7 +125,7 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) -> ``hint_block`` via Commerce's ``JobSubmitted`` event. If called directly without ``hint_block`` a 1 000-block fallback window is used. """ - _TIGHT = 10 # blocks either side when hint is known + _TIGHT = 10 # blocks either side when hint is known _FALLBACK = 1_000 try: @@ -159,7 +159,9 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) -> f"JobInitialised scan for job {job_id} hit the RPC " f"range/rate limit; retry later" ) from exc - logger.warning("[PolicyClient] get_deliverable_url(%s) event query failed: %s", job_id, exc) + logger.warning( + "[PolicyClient] get_deliverable_url(%s) event query failed: %s", job_id, exc + ) return None if not logs: @@ -188,9 +190,7 @@ def dispute_quorum_snapshot(self, job_id: int) -> int: the snapshot is the threshold ``check`` will use, even if an admin later calls ``setQuorum`` — protects pending disputes from retroactive admin adjustments.""" - return self._call_with_retry( - self.contract.functions.disputeQuorumSnapshot(job_id) - ) + return self._call_with_retry(self.contract.functions.disputeQuorumSnapshot(job_id)) def active_voter_count(self) -> int: return self._call_with_retry(self.contract.functions.activeVoterCount()) diff --git a/python/bnbagent/erc8183/schema.py b/python/bnbagent/erc8183/schema.py index a4083ef..68f5f97 100644 --- a/python/bnbagent/erc8183/schema.py +++ b/python/bnbagent/erc8183/schema.py @@ -102,9 +102,9 @@ def from_dict(cls, d: dict[str, Any]) -> DeliverableManifest: response = d["response"] if "content" not in response: raise ValueError("DeliverableManifest.response must contain 'content'") - for field in ("job_id", "chain_id", "contracts"): - if field not in d: - raise ValueError(f"DeliverableManifest missing required field: '{field}'") + for field_name in ("job_id", "chain_id", "contracts"): + if field_name not in d: + raise ValueError(f"DeliverableManifest missing required field: '{field_name}'") return cls( version=version, job_id=d["job_id"], @@ -180,18 +180,14 @@ def from_dict(cls, d: dict[str, Any]) -> JobDescription: negotiated_at = d["negotiated_at"] if not isinstance(negotiated_at, int) or isinstance(negotiated_at, bool): - raise ValueError( - f"negotiated_at must be int, got {type(negotiated_at).__name__}" - ) + raise ValueError(f"negotiated_at must be int, got {type(negotiated_at).__name__}") quote_expires_at = d.get("quote_expires_at") if quote_expires_at is not None and ( - not isinstance(quote_expires_at, int) - or isinstance(quote_expires_at, bool) + not isinstance(quote_expires_at, int) or isinstance(quote_expires_at, bool) ): raise ValueError( - f"quote_expires_at must be int or null, " - f"got {type(quote_expires_at).__name__}" + f"quote_expires_at must be int or null, got {type(quote_expires_at).__name__}" ) return cls( diff --git a/python/bnbagent/networks/__init__.py b/python/bnbagent/networks/__init__.py index 6d54072..d080fdc 100644 --- a/python/bnbagent/networks/__init__.py +++ b/python/bnbagent/networks/__init__.py @@ -20,9 +20,9 @@ BNB_CHAIN_ADDRESSES, BSC_MAINNET_CHAIN_ID, BSC_TESTNET_CHAIN_ID, - DeployedAddresses, PAYMENT_TOKEN_EIP712_NAME, PAYMENT_TOKEN_EIP712_VERSION, + DeployedAddresses, get_address, known_payment_tokens, ) diff --git a/python/bnbagent/signing/policy.py b/python/bnbagent/signing/policy.py index 515e1ce..a2f3d12 100644 --- a/python/bnbagent/signing/policy.py +++ b/python/bnbagent/signing/policy.py @@ -35,10 +35,12 @@ # at github.com/Uniswap/permit2 (allowance-transfer: PermitSingle/PermitBatch; # signature-transfer: PermitTransferFrom/PermitBatchTransferFrom). -EIP3009_TYPES: frozenset[str] = frozenset({ - "TransferWithAuthorization", - "ReceiveWithAuthorization", -}) +EIP3009_TYPES: frozenset[str] = frozenset( + { + "TransferWithAuthorization", + "ReceiveWithAuthorization", + } +) # Field-shape of both EIP-3009 authorization structs (name, solidity type), # in declaration order. EIP-712 hashes the field names, types *and* order into @@ -54,21 +56,25 @@ ("nonce", "bytes32"), ) -PERMIT_UNBOUNDED_TYPES: frozenset[str] = frozenset({ - "Permit", # EIP-2612 — long-lived allowance to a spender - "PermitSingle", # Permit2 AllowanceTransfer — long-lived allowance - "PermitBatch", # Permit2 AllowanceTransfer (batch) -}) +PERMIT_UNBOUNDED_TYPES: frozenset[str] = frozenset( + { + "Permit", # EIP-2612 — long-lived allowance to a spender + "PermitSingle", # Permit2 AllowanceTransfer — long-lived allowance + "PermitBatch", # Permit2 AllowanceTransfer (batch) + } +) # Permit2 SignatureTransfer family — opt-in only, NOT in default allowlist. # These are *safer* than the unbounded family because the spender contract # binds (to, requestedAmount) at call time, but full enforcement requires # witness validation we don't yet do. Callers explicitly extend the policy # to use these. -PERMIT2_SIGNATURE_TRANSFER_TYPES: frozenset[str] = frozenset({ - "PermitTransferFrom", - "PermitBatchTransferFrom", -}) +PERMIT2_SIGNATURE_TRANSFER_TYPES: frozenset[str] = frozenset( + { + "PermitTransferFrom", + "PermitBatchTransferFrom", + } +) @dataclass(frozen=True) @@ -112,7 +118,7 @@ class SigningPolicy: # ── Factory presets ──────────────────────────────────────────────── @classmethod - def strict_default(cls) -> "SigningPolicy": + def strict_default(cls) -> SigningPolicy: """Recommended fail-closed default for direct-SDK callers. Defaults are deliberately narrow: @@ -141,12 +147,17 @@ def strict_default(cls) -> "SigningPolicy": #: Environment values (case-insensitive) that block ``permissive()`` #: construction unless the caller passes ``allow_in_production=True``. - PRODUCTION_ENV_MARKERS: frozenset[str] = frozenset({ - "prod", "production", "live", "mainnet-prod", - }) + PRODUCTION_ENV_MARKERS: frozenset[str] = frozenset( + { + "prod", + "production", + "live", + "mainnet-prod", + } + ) @classmethod - def permissive(cls, *, allow_in_production: bool = False) -> "SigningPolicy": + def permissive(cls, *, allow_in_production: bool = False) -> SigningPolicy: """⚠️ Testing-only escape: allow_unknown_domain=True and empty deny/allow. Refuses to construct when ``ENV`` or ``ENVIRONMENT`` env vars indicate @@ -175,7 +186,9 @@ def permissive(cls, *, allow_in_production: bool = False) -> "SigningPolicy": logger.warning( "SigningPolicy.permissive() in use — POLICY DISABLED. " "This bypasses ALL signing guards; only acceptable in tests. " - "(env=%r, allow_in_production=%s)", env_raw, allow_in_production, + "(env=%r, allow_in_production=%s)", + env_raw, + allow_in_production, ) return cls( domain_allowlist=frozenset(), @@ -197,7 +210,7 @@ def extend( max_validity_window_seconds: int | None = None, max_future_validity_seconds: int | None = None, allow_unknown_domain: bool | None = None, - ) -> "SigningPolicy": + ) -> SigningPolicy: """Return a new policy with extended/overridden fields. Set-like arguments are *unioned* with the current value (additive). @@ -205,9 +218,7 @@ def extend( """ kwargs: dict[str, Any] = {} if domain_allowlist is not None: - kwargs["domain_allowlist"] = self.domain_allowlist | frozenset( - domain_allowlist - ) + kwargs["domain_allowlist"] = self.domain_allowlist | frozenset(domain_allowlist) if primary_type_allowlist is not None: kwargs["primary_type_allowlist"] = self.primary_type_allowlist | frozenset( primary_type_allowlist @@ -218,8 +229,7 @@ def extend( ) if validity_required_primary_types is not None: kwargs["validity_required_primary_types"] = ( - self.validity_required_primary_types - | frozenset(validity_required_primary_types) + self.validity_required_primary_types | frozenset(validity_required_primary_types) ) if max_validity_window_seconds is not None: kwargs["max_validity_window_seconds"] = max_validity_window_seconds @@ -238,21 +248,17 @@ def to_dict(self) -> dict[str, Any]: nested lists (TOML-friendly). Round-trips via :meth:`from_dict`. """ return { - "domain_allowlist": sorted( - [list(pair) for pair in self.domain_allowlist] - ), + "domain_allowlist": sorted([list(pair) for pair in self.domain_allowlist]), "primary_type_allowlist": sorted(self.primary_type_allowlist), "primary_type_denylist": sorted(self.primary_type_denylist), - "validity_required_primary_types": sorted( - self.validity_required_primary_types - ), + "validity_required_primary_types": sorted(self.validity_required_primary_types), "max_validity_window_seconds": self.max_validity_window_seconds, "max_future_validity_seconds": self.max_future_validity_seconds, "allow_unknown_domain": self.allow_unknown_domain, } @classmethod - def from_dict(cls, d: dict[str, Any]) -> "SigningPolicy": + def from_dict(cls, d: dict[str, Any]) -> SigningPolicy: """Reconstruct a SigningPolicy from its :meth:`to_dict` output. Missing keys fall back to the dataclass defaults (empty sets / @@ -269,8 +275,7 @@ def from_dict(cls, d: dict[str, Any]) -> "SigningPolicy": for i, entry in enumerate(raw_domains): if not isinstance(entry, (list, tuple)) or len(entry) != 2: raise ValueError( - f"domain_allowlist[{i}] must be a [chain_id, address] " - f"pair, got {entry!r}" + f"domain_allowlist[{i}] must be a [chain_id, address] pair, got {entry!r}" ) domain_pairs.add((int(entry[0]), str(entry[1]))) return cls( @@ -298,12 +303,8 @@ def __str__(self) -> str: lines.append(f" - chain_id={cid} verifyingContract={addr}") if n_domains == 0: lines.append(" (none)") - lines.append( - f" primary_type_allowlist={sorted(self.primary_type_allowlist) or '(any)'}" - ) - lines.append( - f" primary_type_denylist={sorted(self.primary_type_denylist) or '(none)'}" - ) + lines.append(f" primary_type_allowlist={sorted(self.primary_type_allowlist) or '(any)'}") + lines.append(f" primary_type_denylist={sorted(self.primary_type_denylist) or '(none)'}") lines.append( f" validity: window<={self.max_validity_window_seconds}s, " f"future<={self.max_future_validity_seconds}s, " diff --git a/python/bnbagent/storage/__init__.py b/python/bnbagent/storage/__init__.py index d116b23..bfd1ed9 100644 --- a/python/bnbagent/storage/__init__.py +++ b/python/bnbagent/storage/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from .storage_provider import StorageProvider from .local_storage_provider import LocalStorageProvider +from .storage_provider import StorageProvider from .sync_utils import upload_sync __all__ = [ diff --git a/python/bnbagent/wallets/evm_wallet_provider.py b/python/bnbagent/wallets/evm_wallet_provider.py index 877b716..e525835 100644 --- a/python/bnbagent/wallets/evm_wallet_provider.py +++ b/python/bnbagent/wallets/evm_wallet_provider.py @@ -13,6 +13,7 @@ from __future__ import annotations +import inspect import json import logging import os @@ -20,13 +21,12 @@ from pathlib import Path from typing import Any -import inspect - from eth_account import Account from eth_account.messages import encode_defunct from eth_account.signers.local import LocalAccount -from ..signing import SigningPolicy, check as _policy_check +from ..signing import SigningPolicy +from ..signing import check as _policy_check from .capabilities import CALLS_ARBITRARY, PAYMASTER_SPONSOR from .wallet_provider import WalletProvider @@ -170,8 +170,7 @@ def _import_private_key(self, private_key: str) -> None: if self._persist: self._save_keystore() logger.info( - "Private key imported and encrypted: %s " - "(PRIVATE_KEY can be removed from env)", + "Private key imported and encrypted: %s (PRIVATE_KEY can be removed from env)", self._account.address, ) except Exception as e: @@ -236,7 +235,9 @@ def _save_keystore(self) -> None: # Atomic write fd, temp_path = tempfile.mkstemp( - dir=self._wallets_dir, prefix=".ks_", suffix=".tmp", + dir=self._wallets_dir, + prefix=".ks_", + suffix=".tmp", ) try: with os.fdopen(fd, "w") as f: diff --git a/python/bnbagent/wallets/turnkey/client.py b/python/bnbagent/wallets/turnkey/client.py index fe32c79..4dc9d7e 100644 --- a/python/bnbagent/wallets/turnkey/client.py +++ b/python/bnbagent/wallets/turnkey/client.py @@ -142,7 +142,8 @@ def sign_transaction(self, *, sign_with: str, unsigned_transaction: str) -> str: def _submit(self, path: str, body: dict[str, Any]) -> dict[str, Any]: activity = self._post(path, body) - for _ in range(_POLL_ATTEMPTS): + polls_remaining = _POLL_ATTEMPTS + while True: status = activity.get("status") if status == _ACTIVITY_TERMINAL_OK: return activity @@ -152,6 +153,8 @@ def _submit(self, path: str, body: dict[str, Any]) -> dict[str, Any]: f"{activity.get('failure') or activity.get('type', '')}".strip(), activity_status=status, ) + if polls_remaining == 0: + break time.sleep(_POLL_INTERVAL_S) activity = self._post( "/public/v1/query/get_activity", @@ -160,6 +163,7 @@ def _submit(self, path: str, body: dict[str, Any]) -> dict[str, Any]: "activityId": activity.get("id", ""), }, ) + polls_remaining -= 1 raise TurnkeyApiError( "Turnkey activity still pending after " f"{_POLL_ATTEMPTS * _POLL_INTERVAL_S:.0f}s of polling", diff --git a/python/bnbagent/wallets/turnkey/provider.py b/python/bnbagent/wallets/turnkey/provider.py index 95a28c3..76cf22c 100644 --- a/python/bnbagent/wallets/turnkey/provider.py +++ b/python/bnbagent/wallets/turnkey/provider.py @@ -32,6 +32,7 @@ import json import os +import re from typing import Any import rlp @@ -76,7 +77,7 @@ def _map_vendor_error(op: str, error: Exception) -> Exception: "billed signatures/month; pay-as-you-go $0.10/signature) — check " "the Turnkey billing dashboard." ) - if status == 429 or "rate limit" in lowered or "rate_limit" in lowered: + if status == 429 or re.search(r"rate.?limit", message, re.IGNORECASE): return RuntimeError( f"Turnkey rate limit hit during {op} (free tier allows 1 " "request/second) — pace calls or upgrade the plan." diff --git a/python/bnbagent/wallets/twak_provider.py b/python/bnbagent/wallets/twak_provider.py index d6a3e41..b03b125 100644 --- a/python/bnbagent/wallets/twak_provider.py +++ b/python/bnbagent/wallets/twak_provider.py @@ -238,9 +238,7 @@ class TWAKProvider(WalletProvider, IntentExecutor): # sign.message derives automatically from the override below; twak has # no sign_transaction / sign_typed_data, so the base defaults raise. # x402.pay: served by the delegated TwakX402Payer (make_x402_payer). - _extra_capabilities = frozenset( - {BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY} - ) + _extra_capabilities = frozenset({BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY}) def __init__( self, @@ -292,11 +290,7 @@ def _run(self, args: list[str]) -> dict[str, Any]: an error. """ cmd = [self._twak_bin, *args, "--json"] - env = ( - {**os.environ, "HOME": str(self._home)} - if self._home is not None - else None - ) + env = {**os.environ, "HOME": str(self._home)} if self._home is not None else None try: proc = subprocess.run( cmd, @@ -312,7 +306,9 @@ def _run(self, args: list[str]) -> dict[str, Any]: "configure it (see TWAKProvider prerequisites)." ) from e except subprocess.TimeoutExpired as e: - raise RuntimeError(f"twak command timed out after {self._timeout}s: {_redact(cmd)}") from e + raise RuntimeError( + f"twak command timed out after {self._timeout}s: {_redact(cmd)}" + ) from e if proc.returncode != 0: # Quirk (field-verified v0.18.0): `x402 quote` exits non-zero on an @@ -363,10 +359,7 @@ def _format_error(cmd: list[str], stdout: str, stderr: str) -> str: ) else: hint = _SETUP_HINT - return ( - f"twak command failed ({_redact(cmd)}): {detail or ''}. " - f"{hint}" - ) + return f"twak command failed ({_redact(cmd)}): {detail or ''}. {hint}" @staticmethod def _extract_tx_hash(data: dict[str, Any]) -> str | None: @@ -410,16 +403,9 @@ def _lookup_address(self) -> None: data = self._run(["wallet", "address", "--chain", self._chain]) addr = data.get("address") or data.get("wallet") if not addr: - raise RuntimeError( - f"twak `wallet address` did not return an address: {data!r}" - ) - if ( - self._expected_address is not None - and addr.lower() != self._expected_address.lower() - ): - raise WalletIdentityMismatch( - expected=self._expected_address, actual=addr - ) + raise RuntimeError(f"twak `wallet address` did not return an address: {data!r}") + if self._expected_address is not None and addr.lower() != self._expected_address.lower(): + raise WalletIdentityMismatch(expected=self._expected_address, actual=addr) self._address = addr @property @@ -578,13 +564,10 @@ def sign_message(self, message: str) -> dict[str, Any]: signature = "0x" + signature digest = "0x" + bytes(defunct_hash_message(text=message)).hex() try: - recovered = Account.recover_message( - encode_defunct(text=message), signature=signature - ) + recovered = Account.recover_message(encode_defunct(text=message), signature=signature) except Exception as e: raise RuntimeError( - f"twak `sign-message` returned a malformed signature " - f"({signature[:20]}...): {e}" + f"twak `sign-message` returned a malformed signature ({signature[:20]}...): {e}" ) from e if recovered.lower() != self.address.lower(): raise RuntimeError( @@ -809,16 +792,21 @@ def _register(self, kwargs: dict[str, Any]) -> dict[str, Any]: for entry in metadata: args += ["--metadata", f"{entry['key']}={entry['value']}"] data = self._run([*args, *self._paymaster_args(), "--chain", self._chain]) - return self._tx_result( - data, agentId=_as_int(data.get("agentId")), owner=data.get("owner") - ) + return self._tx_result(data, agentId=_as_int(data.get("agentId")), owner=data.get("owner")) def _set_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]: data = self._run( [ - "erc8004", "set-metadata", str(kwargs["agent_id"]), - "--key", kwargs["key"], "--value", kwargs["value"], - *self._paymaster_args(), "--chain", self._chain, + "erc8004", + "set-metadata", + str(kwargs["agent_id"]), + "--key", + kwargs["key"], + "--value", + kwargs["value"], + *self._paymaster_args(), + "--chain", + self._chain, ] ) return self._tx_result(data) @@ -826,9 +814,14 @@ def _set_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]: def _set_agent_uri(self, kwargs: dict[str, Any]) -> dict[str, Any]: data = self._run( [ - "erc8004", "set-uri", str(kwargs["agent_id"]), - "--uri", kwargs["agent_uri"], - *self._paymaster_args(), "--chain", self._chain, + "erc8004", + "set-uri", + str(kwargs["agent_id"]), + "--uri", + kwargs["agent_uri"], + *self._paymaster_args(), + "--chain", + self._chain, ] ) return self._tx_result(data) @@ -839,19 +832,29 @@ def _erc8183(self, command: str, job_id: Any, *extra: str) -> dict[str, Any]: """Run ``twak erc8183 [extra...] --chain ``.""" data = self._run( [ - "erc8183", command, str(job_id), *extra, - *self._paymaster_args(), "--chain", self._chain, + "erc8183", + command, + str(job_id), + *extra, + *self._paymaster_args(), + "--chain", + self._chain, ] ) return self._tx_result(data) def _create_job(self, kwargs: dict[str, Any]) -> dict[str, Any]: args = [ - "erc8183", "create-job", - "--provider", kwargs["provider"], - "--evaluator", kwargs["evaluator"], - "--expires-at", str(kwargs["expired_at"]), - "--description", kwargs["description"], + "erc8183", + "create-job", + "--provider", + kwargs["provider"], + "--evaluator", + kwargs["evaluator"], + "--expires-at", + str(kwargs["expired_at"]), + "--description", + kwargs["description"], ] hook = kwargs.get("hook") if hook and hook.lower() != _ZERO_ADDRESS: @@ -865,14 +868,20 @@ def _create_job(self, kwargs: dict[str, Any]) -> dict[str, Any]: def _set_provider(self, kwargs: dict[str, Any]) -> dict[str, Any]: return self._erc8183( - "set-provider", kwargs["job_id"], - "--provider", kwargs["provider"], *self._opt_params(kwargs), + "set-provider", + kwargs["job_id"], + "--provider", + kwargs["provider"], + *self._opt_params(kwargs), ) def _set_budget(self, kwargs: dict[str, Any]) -> dict[str, Any]: return self._erc8183( - "set-budget", kwargs["job_id"], - "--amount", str(kwargs["amount"]), *self._opt_params(kwargs), + "set-budget", + kwargs["job_id"], + "--amount", + str(kwargs["amount"]), + *self._opt_params(kwargs), ) def _fund(self, kwargs: dict[str, Any]) -> dict[str, Any]: @@ -882,10 +891,15 @@ def _fund(self, kwargs: dict[str, Any]) -> dict[str, Any]: # pre-check could not (gaps S-2, shipped). data = self._run( [ - "erc8183", "fund", str(kwargs["job_id"]), - "--expected-budget", str(kwargs["expected_budget"]), + "erc8183", + "fund", + str(kwargs["job_id"]), + "--expected-budget", + str(kwargs["expected_budget"]), *self._opt_params(kwargs), - *self._paymaster_args(), "--chain", self._chain, + *self._paymaster_args(), + "--chain", + self._chain, ] ) result = self._tx_result(data) @@ -899,8 +913,11 @@ def _submit(self, kwargs: dict[str, Any]) -> dict[str, Any]: # JobInitialised event — the seller role works end-to-end. deliverable: bytes = kwargs["deliverable"] return self._erc8183( - "submit", kwargs["job_id"], - "--deliverable", "0x" + deliverable.hex(), *self._opt_params(kwargs), + "submit", + kwargs["job_id"], + "--deliverable", + "0x" + deliverable.hex(), + *self._opt_params(kwargs), ) def _complete(self, kwargs: dict[str, Any]) -> dict[str, Any]: @@ -914,17 +931,13 @@ def _reason_op(self, command: str, kwargs: dict[str, Any]) -> dict[str, Any]: reason: bytes = kwargs.get("reason") or b"" if reason and reason != _ZERO_REASON: # twak defaults --reason to zero extra = ["--reason", "0x" + reason.hex()] - return self._erc8183( - command, kwargs["job_id"], *extra, *self._opt_params(kwargs) - ) + return self._erc8183(command, kwargs["job_id"], *extra, *self._opt_params(kwargs)) def _claim_refund(self, kwargs: dict[str, Any]) -> dict[str, Any]: return self._erc8183("claim-refund", kwargs["job_id"]) def _register_job(self, kwargs: dict[str, Any]) -> dict[str, Any]: - return self._erc8183( - "register-job", kwargs["job_id"], "--policy", kwargs["policy"] - ) + return self._erc8183("register-job", kwargs["job_id"], "--policy", kwargs["policy"]) def _settle(self, kwargs: dict[str, Any]) -> dict[str, Any]: extra: list[str] = [] diff --git a/python/bnbagent/wallets/wallet_provider.py b/python/bnbagent/wallets/wallet_provider.py index 2c043cb..160d8f1 100644 --- a/python/bnbagent/wallets/wallet_provider.py +++ b/python/bnbagent/wallets/wallet_provider.py @@ -174,10 +174,7 @@ def make_x402_payer(self, **payer_kwargs: Any) -> X402Payer: """ raise UnsupportedWalletOperation( X402_PAY, - reason=( - f"the {self.kind!r} wallet has no x402 payment backend in " - "the SDK yet" - ), + reason=(f"the {self.kind!r} wallet has no x402 payment backend in the SDK yet"), alternative=( "wallets with sign.typed_data can use X402Signer directly " "today; a local payer that upgrades this default is planned" @@ -207,14 +204,13 @@ def sign_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]: 'gasPrice', 'nonce', 'data', 'chainId' Returns: - dict: Signed transaction dictionary with 'rawTransaction', 'hash', 'r', 's', 'v' + dict: Signed transaction dictionary with 'rawTransaction', 'hash', 'r', 's', 'v'. + For legacy transactions, ``v`` is the EIP-155 value; for typed + transactions, it is the y-parity bit (0 or 1). """ raise UnsupportedWalletOperation( SIGN_TRANSACTION, - reason=( - f"the {self.kind!r} wallet does not implement raw-transaction " - "signing" - ), + reason=(f"the {self.kind!r} wallet does not implement raw-transaction signing"), alternative=( "use a wallet whose capabilities() include 'sign.transaction', " "or route high-level operations through the wallet's own " @@ -241,13 +237,8 @@ def sign_message(self, message: str) -> dict[str, Any]: """ raise UnsupportedWalletOperation( SIGN_MESSAGE, - reason=( - f"the {self.kind!r} wallet does not implement EIP-191 " - "personal-sign" - ), - alternative=( - "use a wallet whose capabilities() include 'sign.message'" - ), + reason=(f"the {self.kind!r} wallet does not implement EIP-191 personal-sign"), + alternative=("use a wallet whose capabilities() include 'sign.message'"), ) def sign_typed_data( @@ -307,10 +298,7 @@ def sign_typed_data( """ raise UnsupportedWalletOperation( SIGN_TYPED_DATA, - reason=( - f"the {self.kind!r} wallet does not implement EIP-712 " - "typed-data signing" - ), + reason=(f"the {self.kind!r} wallet does not implement EIP-712 typed-data signing"), alternative=( "use a wallet whose capabilities() include 'sign.typed_data', " "or a delegated flow that signs internally (e.g. the x402 " diff --git a/python/bnbagent/x402/signer.py b/python/bnbagent/x402/signer.py index c1cb65b..ffa17bb 100644 --- a/python/bnbagent/x402/signer.py +++ b/python/bnbagent/x402/signer.py @@ -30,7 +30,6 @@ from .budget import SessionBudgetTracker from .errors import ( X402AmountExceededError, - X402BudgetExhaustedError, X402PolicyError, X402RecipientMismatchError, ) @@ -164,9 +163,7 @@ def sign_payment( # test below and neutralises the session budget. Refuse before # reserve() so a rejected call still costs no budget. if value < 0: - raise X402AmountExceededError( - f"message['value'] must be non-negative, got {value}" - ) + raise X402AmountExceededError(f"message['value'] must be non-negative, got {value}") cap = self._max_value.get(verifying) if cap is not None and value > cap: raise X402AmountExceededError( @@ -221,6 +218,9 @@ def sign_payment( logger.info( "x402 payment signed: token=%s value=%s to=%s expected_to=%s", - verifying, value, msg_to, expected_to, + verifying, + value, + msg_to, + expected_to, ) return signed diff --git a/python/examples/a2a-agent/scripts/buyer.py b/python/examples/a2a-agent/scripts/buyer.py index 9fe8f0b..83a8863 100644 --- a/python/examples/a2a-agent/scripts/buyer.py +++ b/python/examples/a2a-agent/scripts/buyer.py @@ -33,7 +33,9 @@ import httpx from dotenv import load_dotenv -load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))) +load_dotenv( + Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")) +) NETWORK = os.getenv("NETWORK", "bsc-testnet") @@ -74,7 +76,11 @@ def agent_card_url(base: str) -> str: parts = urlsplit(base) if not parts.scheme or not parts.netloc: url = base.rstrip("/") - return url if url.endswith("/.well-known/agent-card.json") else f"{url}/.well-known/agent-card.json" + return ( + url + if url.endswith("/.well-known/agent-card.json") + else f"{url}/.well-known/agent-card.json" + ) path = parts.path.rstrip("/") if not path.endswith("/.well-known/agent-card.json"): path += "/.well-known/agent-card.json" @@ -154,7 +160,9 @@ def fund_job(quote: dict) -> int | None: from bnbagent import ERC8183Client, EVMWalletProvider from bnbagent.erc8183.negotiation import build_job_description - wallet = EVMWalletProvider(password=os.getenv("BUYER_WALLET_PASSWORD", "demo-password"), private_key=buyer_key) + wallet = EVMWalletProvider( + password=os.getenv("BUYER_WALLET_PASSWORD", "demo-password"), private_key=buyer_key + ) client = ERC8183Client(wallet_provider=wallet, network=NETWORK) provider = quote["provider_address"] diff --git a/python/examples/a2a-agent/scripts/register.py b/python/examples/a2a-agent/scripts/register.py index db06742..c0d436e 100644 --- a/python/examples/a2a-agent/scripts/register.py +++ b/python/examples/a2a-agent/scripts/register.py @@ -22,9 +22,11 @@ from dotenv import load_dotenv -load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))) +load_dotenv( + Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")) +) -from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider +from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider # noqa: E402 def _make_wallet(network: str): diff --git a/python/examples/a2a-agent/src/server.py b/python/examples/a2a-agent/src/server.py index b6e6494..903439f 100644 --- a/python/examples/a2a-agent/src/server.py +++ b/python/examples/a2a-agent/src/server.py @@ -40,11 +40,13 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))) +load_dotenv( + Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")) +) -from bnbagent import EVMWalletProvider -from bnbagent.erc8183 import ERC8183Client, NegotiationHandler -from bnbagent.utils import RateLimitExceeded, SlidingWindowLimiter +from bnbagent import EVMWalletProvider # noqa: E402 +from bnbagent.erc8183 import ERC8183Client, NegotiationHandler # noqa: E402 +from bnbagent.utils import RateLimitExceeded, SlidingWindowLimiter # noqa: E402 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") logger = logging.getLogger("a2a-agent") @@ -112,8 +114,8 @@ "id": "negotiate-erc8183-job", "name": "Negotiate an ERC-8183 job", "description": ( - "Send a data part {\"skill\": \"negotiate-erc8183-job\", " - "\"task_description\": \"...\", \"terms\": {...}} and receive a " + 'Send a data part {"skill": "negotiate-erc8183-job", ' + '"task_description": "...", "terms": {...}} and receive a ' "wallet-signed quote (price, currency, negotiation_hash, provider_sig). " "Anchor the returned envelope on-chain via createJob." ), @@ -125,7 +127,7 @@ "id": "erc8183-job-status", "name": "ERC-8183 job status", "description": ( - "Send {\"skill\": \"erc8183-job-status\", \"job_id\": } for a " + 'Send {"skill": "erc8183-job-status", "job_id": } for a ' "read-only on-chain job lookup." ), "tags": ["erc8183", "status"], @@ -162,7 +164,11 @@ def _agent_message(data: dict[str, Any]) -> dict[str, Any]: def _extract_data_part(message: dict[str, Any]) -> dict[str, Any] | None: for part in message.get("parts", []): - if isinstance(part, dict) and part.get("kind") == "data" and isinstance(part.get("data"), dict): + if ( + isinstance(part, dict) + and part.get("kind") == "data" + and isinstance(part.get("data"), dict) + ): return part["data"] return None @@ -197,7 +203,8 @@ async def a2a_endpoint(request: Request): task_description = data.get("task_description") if not isinstance(terms, dict) or not isinstance(task_description, str): return _rpc_error( - req_id, -32602, + req_id, + -32602, "negotiate-erc8183-job requires 'task_description' (string) and 'terms' (object)", ) try: diff --git a/python/examples/agent-server/scripts/register.py b/python/examples/agent-server/scripts/register.py index 610e69d..7c38753 100644 --- a/python/examples/agent-server/scripts/register.py +++ b/python/examples/agent-server/scripts/register.py @@ -47,16 +47,16 @@ def main(): agent_endpoint = f"{agent_host}/erc8183/status" print(f""" -{'='*60} +{"=" * 60} ERC-8004 Agent Registration -{'='*60} +{"=" * 60} Name: {agent_name} Description: {agent_description[:60]}... Endpoint: {agent_endpoint} """) try: - from bnbagent import ERC8004Agent, AgentEndpoint, EVMWalletProvider + from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider except ImportError: print("Error: bnbagent SDK not installed") print("Run: pip install bnbagent") @@ -112,7 +112,7 @@ def main(): and agent.get("name", "").lower() == agent_name.lower() ): existing_id = agent["token_id"] - print(f"\n Agent already registered!") + print("\n Agent already registered!") print(f" Agent ID: {existing_id}") print(f" Name: {agent['name']}") @@ -135,20 +135,20 @@ def main(): result = sdk.register_agent(agent_uri=agent_uri) print(f""" -{'='*60} +{"=" * 60} Registration Successful! -{'='*60} - Agent ID: {result['agentId']} - TX Hash: {result['transactionHash']} +{"=" * 60} + Agent ID: {result["agentId"]} + TX Hash: {result["transactionHash"]} Owner: {sdk.wallet_address} View on explorer: - https://testnet.bscscan.com/tx/{result['transactionHash']} + https://testnet.bscscan.com/tx/{result["transactionHash"]} Save this Agent ID for client configuration: - AGENT_ID={result['agentId']} + AGENT_ID={result["agentId"]} -{'='*60} +{"=" * 60} """) except Exception as e: diff --git a/python/examples/agent-server/scripts/run_agent.py b/python/examples/agent-server/scripts/run_agent.py index 74f53fd..01d7062 100644 --- a/python/examples/agent-server/scripts/run_agent.py +++ b/python/examples/agent-server/scripts/run_agent.py @@ -31,6 +31,6 @@ # pass env file choice to service.py via env var before it loads dotenv os.environ.setdefault("ENV_FILE", args.env) - from service import app, PORT + from service import PORT, app uvicorn.run(app, host="0.0.0.0", port=PORT) diff --git a/python/examples/agent-server/scripts/run_agent_mount.py b/python/examples/agent-server/scripts/run_agent_mount.py index 020c772..1332394 100644 --- a/python/examples/agent-server/scripts/run_agent_mount.py +++ b/python/examples/agent-server/scripts/run_agent_mount.py @@ -1,6 +1,7 @@ """Run the Blockchain News Agent server (mount mode).""" -import sys + import os +import sys # Add src to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) diff --git a/python/examples/agent-server/src/erc8183_server.py b/python/examples/agent-server/src/erc8183_server.py index 46e67c0..e78aa48 100644 --- a/python/examples/agent-server/src/erc8183_server.py +++ b/python/examples/agent-server/src/erc8183_server.py @@ -117,15 +117,11 @@ def create_erc8183_state(config: ERC8183Config | None = None) -> ERC8183State: def _build_negotiate_limiter() -> SlidingWindowLimiter: """Read ERC8183_NEGOTIATE_RATE_LIMIT / ERC8183_NEGOTIATE_RATE_WINDOW from env.""" raw_max = get_env("NEGOTIATE_RATE_LIMIT", "120", prefix=ERC8183_ENV_PREFIX) or "120" - raw_window = ( - get_env("NEGOTIATE_RATE_WINDOW", "60.0", prefix=ERC8183_ENV_PREFIX) or "60.0" - ) + raw_window = get_env("NEGOTIATE_RATE_WINDOW", "60.0", prefix=ERC8183_ENV_PREFIX) or "60.0" try: max_requests = int(raw_max) except ValueError: - logger.warning( - f"[ERC-8183] ERC8183_NEGOTIATE_RATE_LIMIT={raw_max!r} invalid, using 120" - ) + logger.warning(f"[ERC-8183] ERC8183_NEGOTIATE_RATE_LIMIT={raw_max!r} invalid, using 120") max_requests = 120 try: window_seconds = float(raw_window) @@ -134,9 +130,7 @@ def _build_negotiate_limiter() -> SlidingWindowLimiter: f"[ERC-8183] ERC8183_NEGOTIATE_RATE_WINDOW={raw_window!r} invalid, using 60.0" ) window_seconds = 60.0 - raw_max_keys = ( - get_env("RATE_LIMIT_MAX_KEYS", "10000", prefix=ERC8183_ENV_PREFIX) or "10000" - ) + raw_max_keys = get_env("RATE_LIMIT_MAX_KEYS", "10000", prefix=ERC8183_ENV_PREFIX) or "10000" try: max_keys = int(raw_max_keys) except ValueError: @@ -208,7 +202,7 @@ async def negotiate(request: Request): negotiate_limiter.check(client_ip) except RateLimitExceeded: # The SDK limiter is transport-agnostic; this HTTP shell maps it to 429. - raise HTTPException(status_code=429, detail="Too many requests") + raise HTTPException(status_code=429, detail="Too many requests") from None try: body = await request.json() @@ -216,12 +210,7 @@ async def negotiate(request: Request): return JSONResponse({"error": "Invalid JSON"}, status_code=400) if not isinstance(body, dict) or "terms" not in body: return JSONResponse( - { - "error": ( - "Request must include 'terms' with" - " deliverables, quality_standards" - ) - }, + {"error": ("Request must include 'terms' with deliverables, quality_standards")}, status_code=400, ) try: @@ -371,9 +360,7 @@ def _schedule_retry(job_id: int, reason) -> None: attempts = retry_attempts.get(job_id, 0) + 1 if attempts >= _MAX_JOB_ATTEMPTS: retry_attempts.pop(job_id, None) - logger.error( - f"[ERC-8183] Job #{job_id} giving up after {attempts} attempts: {reason}" - ) + logger.error(f"[ERC-8183] Job #{job_id} giving up after {attempts} attempts: {reason}") return retry_attempts[job_id] = attempts logger.warning( @@ -404,16 +391,12 @@ async def _funded_poll_loop(): for job in jobs: await _attempt_job(job["jobId"]) else: - logger.warning( - f"[ERC-8183] Funded-poll error: {result.get('error')}" - ) + logger.warning(f"[ERC-8183] Funded-poll error: {result.get('error')}") except Exception as exc: logger.error(f"[ERC-8183] Funded-poll iteration failed: {exc}") try: - await asyncio.wait_for( - stop_event.wait(), timeout=effective_poll_interval - ) + await asyncio.wait_for(stop_event.wait(), timeout=effective_poll_interval) break except asyncio.TimeoutError: continue diff --git a/python/examples/agent-server/src/service.py b/python/examples/agent-server/src/service.py index ea9f44d..b48a677 100644 --- a/python/examples/agent-server/src/service.py +++ b/python/examples/agent-server/src/service.py @@ -14,9 +14,12 @@ Environment (agent-server/.env): RPC_URL, NETWORK — Required (RPC + network key) - PRIVATE_KEY — Recommended (imported on first run; auto-generates if omitted) + PRIVATE_KEY — Recommended (imported on first run; + auto-generates if omitted) WALLET_PASSWORD — Required (keystore password) - ERC8183_COMMERCE_ADDRESS, ERC8183_ROUTER_ADDRESS, ERC8183_POLICY_ADDRESS — Optional overrides (defaults from NETWORK) + ERC8183_COMMERCE_ADDRESS, ERC8183_ROUTER_ADDRESS, + ERC8183_POLICY_ADDRESS — Optional overrides + (defaults from NETWORK) STORAGE_API_KEY — Required for IPFS upload (when swapping to IPFSStorageProvider) ERC8183_AGENT_URL=http://localhost:8003/erc8183 — Required for LocalStorageProvider ERC8183_SERVICE_PRICE=1000000000000000000 — Negotiation price (1 U) @@ -32,18 +35,19 @@ import os from pathlib import Path +from ddgs import DDGS from dotenv import load_dotenv from fastapi import HTTPException from pydantic import BaseModel -from ddgs import DDGS # Load .env from project root (one level up from src/) env_file = os.path.basename(os.environ.get("ENV_FILE", ".env")) load_dotenv(Path(__file__).resolve().parent.parent / env_file) # SDK imports + the example's own HTTP server factory (src/erc8183_server.py) -from bnbagent.erc8183.config import ERC8183Config -from erc8183_server import create_erc8183_app +from erc8183_server import create_erc8183_app # noqa: E402 + +from bnbagent.erc8183.config import ERC8183Config # noqa: E402 logging.basicConfig( level=logging.INFO, @@ -58,7 +62,8 @@ # Storage backend — pick ONE of the two options below by uncommenting it. # (a) Local filesystem (default) -from bnbagent.storage import LocalStorageProvider +from bnbagent.storage import LocalStorageProvider # noqa: E402 + _storage = LocalStorageProvider.from_env() # (b) IPFS via Pinata — set STORAGE_API_KEY (Pinata JWT) in .env first. @@ -92,7 +97,7 @@ def format_news_results(query: str, raw_results: list[dict]) -> str: if not raw_results: return f"No news found for query: {query}" - report = f"# Blockchain News Search Results\n\n" + report = "# Blockchain News Search Results\n\n" report += f"**Query:** {query}\n" report += f"**Results:** {len(raw_results)} items\n\n" report += "---\n\n" @@ -157,9 +162,9 @@ def process_task(job: dict) -> tuple[str, dict]: _storage_info = type(_storage).__name__ print(f""" -{'='*55} +{"=" * 55} Blockchain News Agent (ERC-8183 Provider) -{'='*55} +{"=" * 55} Port: {PORT} Commerce: {config.effective_commerce_address} Router: {config.effective_router_address} @@ -175,7 +180,7 @@ def process_task(job: dict) -> tuple[str, dict]: Direct endpoints (testing): POST /search — Direct news search GET /erc8183/health — Health check -{'='*55} +{"=" * 55} """) @@ -238,7 +243,7 @@ async def search_endpoint(request: SearchRequest): ) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e # --------------------------------------------------------------------------- diff --git a/python/examples/agent-server/src/service_mount.py b/python/examples/agent-server/src/service_mount.py index d435ce7..e8eec9e 100644 --- a/python/examples/agent-server/src/service_mount.py +++ b/python/examples/agent-server/src/service_mount.py @@ -30,18 +30,19 @@ from contextlib import asynccontextmanager from pathlib import Path +from ddgs import DDGS from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from pydantic import BaseModel -from ddgs import DDGS # Load .env from project root (one level up from src/) env_file = os.path.basename(os.environ.get("ENV_FILE", ".env")) load_dotenv(Path(__file__).resolve().parent.parent / env_file) # SDK imports + the example's own HTTP server factory (src/erc8183_server.py) -from bnbagent.erc8183.config import ERC8183Config -from erc8183_server import create_erc8183_app +from erc8183_server import create_erc8183_app # noqa: E402 + +from bnbagent.erc8183.config import ERC8183Config # noqa: E402 logging.basicConfig( level=logging.INFO, @@ -56,7 +57,8 @@ # Storage backend — pick ONE of the three options below by uncommenting it. # (a) Local filesystem (default) -from bnbagent.storage import LocalStorageProvider +from bnbagent.storage import LocalStorageProvider # noqa: E402 + _storage = LocalStorageProvider.from_env() # (b) IPFS via Pinata — set STORAGE_API_KEY (Pinata JWT) in .env first. @@ -92,7 +94,7 @@ def format_news_results(query: str, raw_results: list[dict]) -> str: if not raw_results: return f"No news found for query: {query}" - report = f"# Blockchain News Search Results\n\n" + report = "# Blockchain News Search Results\n\n" report += f"**Query:** {query}\n" report += f"**Results:** {len(raw_results)} items\n\n" report += "---\n\n" @@ -237,7 +239,7 @@ async def search_endpoint(request: SearchRequest): ) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e # --------------------------------------------------------------------------- @@ -248,9 +250,9 @@ async def search_endpoint(request: SearchRequest): import uvicorn print(f""" -{'='*55} +{"=" * 55} Blockchain News Agent (ERC-8183 — Mount Mode) -{'='*55} +{"=" * 55} Port: {PORT} Commerce: {config.effective_commerce_address} Router: {config.effective_router_address} @@ -267,7 +269,7 @@ async def search_endpoint(request: SearchRequest): GET / — Service info POST /search — Direct news search GET /erc8183/health — Health check -{'='*55} +{"=" * 55} """) uvicorn.run(app, host="0.0.0.0", port=PORT) diff --git a/python/examples/agent-server/tests/test_routes_poll.py b/python/examples/agent-server/tests/test_routes_poll.py index afc0147..a929511 100644 --- a/python/examples/agent-server/tests/test_routes_poll.py +++ b/python/examples/agent-server/tests/test_routes_poll.py @@ -8,9 +8,8 @@ import time from unittest.mock import AsyncMock, MagicMock -from fastapi.testclient import TestClient - from erc8183_server import create_erc8183_app +from fastapi.testclient import TestClient def _fake_state(job_ops): @@ -32,9 +31,7 @@ async def get_pending_jobs(): ops.agent_address = "0x" + "aa" * 20 ops.get_pending_jobs = get_pending_jobs ops.verify_job = AsyncMock(side_effect=verify_results) - ops.submit_result = AsyncMock( - return_value={"success": True, "txHash": "0x" + "de" * 32} - ) + ops.submit_result = AsyncMock(return_value={"success": True, "txHash": "0x" + "de" * 32}) return ops @@ -62,7 +59,12 @@ def _valid(job_id=1): class TestFundedPollRetry: def test_transient_verify_failure_retries_then_succeeds(self, monkeypatch): - transient = {"valid": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True} + transient = { + "valid": False, + "error": "Temporary chain/RPC error", + "error_code": "chain_unavailable", + "retryable": True, + } ops = _job_ops( verify_results=[transient, _valid()], pending_jobs=[{"jobId": 1}], @@ -80,7 +82,11 @@ def test_transient_verify_failure_retries_then_succeeds(self, monkeypatch): assert ops.submit_result.await_count == 1 def test_permanent_failure_does_not_retry(self, monkeypatch): - permanent = {"valid": False, "error": "This agent is not the provider", "error_code": "not_assigned"} + permanent = { + "valid": False, + "error": "This agent is not the provider", + "error_code": "not_assigned", + } ops = _job_ops( verify_results=lambda job_id: permanent, pending_jobs=[{"jobId": 1}], @@ -94,7 +100,12 @@ def test_permanent_failure_does_not_retry(self, monkeypatch): ops.submit_result.assert_not_called() def test_retries_are_capped(self, monkeypatch): - transient = {"valid": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True} + transient = { + "valid": False, + "error": "Temporary chain/RPC error", + "error_code": "chain_unavailable", + "retryable": True, + } ops = _job_ops( verify_results=lambda job_id: transient, pending_jobs=[{"jobId": 1}], @@ -110,7 +121,10 @@ def test_retries_are_capped(self, monkeypatch): class TestResponseRoute: - """/response maps get_response's semantic error_code (chain_unavailable→503 vs not_found→404), BUG-06.""" + """Test mapping get_response errors to HTTP statuses (BUG-06). + + ``chain_unavailable`` maps to 503 and ``not_found`` maps to 404. + """ def _http(self, get_response_result, monkeypatch): ops = MagicMock() @@ -124,7 +138,12 @@ def _http(self, get_response_result, monkeypatch): def test_unresolvable_deliverable_forwards_503(self, monkeypatch): http = self._http( - {"success": False, "error": "temporarily unresolvable", "error_code": "chain_unavailable", "retryable": True}, + { + "success": False, + "error": "temporarily unresolvable", + "error_code": "chain_unavailable", + "retryable": True, + }, monkeypatch, ) assert http.get("/erc8183/job/1/response").status_code == 503 @@ -137,7 +156,5 @@ def test_genuine_not_found_is_404(self, monkeypatch): assert http.get("/erc8183/job/1/response").status_code == 404 def test_missing_error_code_defaults_to_404(self, monkeypatch): - http = self._http( - {"success": False, "error": "No storage configured"}, monkeypatch - ) + http = self._http({"success": False, "error": "No storage configured"}, monkeypatch) assert http.get("/erc8183/job/1/response").status_code == 404 diff --git a/python/examples/agent-server/tests/test_routes_startup.py b/python/examples/agent-server/tests/test_routes_startup.py index 0a2a61a..06f6399 100644 --- a/python/examples/agent-server/tests/test_routes_startup.py +++ b/python/examples/agent-server/tests/test_routes_startup.py @@ -3,9 +3,9 @@ from unittest.mock import MagicMock import pytest +from erc8183_server import create_erc8183_state from bnbagent.erc8183.config import ERC8183Config -from erc8183_server import create_erc8183_state from bnbagent.storage.local_storage_provider import LocalStorageProvider @@ -27,10 +27,12 @@ class TestCreateERC8183StateStartupValidation: def test_local_storage_without_agent_url_raises(self, monkeypatch): monkeypatch.setattr( "erc8183_server.ERC8183JobOps.erc8183_client", - property(lambda self: MagicMock( - payment_token="0x" + "00" * 20, - token_decimals=MagicMock(return_value=18), - )), + property( + lambda self: MagicMock( + payment_token="0x" + "00" * 20, + token_decimals=MagicMock(return_value=18), + ) + ), raising=False, ) config = _config(LocalStorageProvider(".agent-data"), agent_url=None) diff --git a/python/examples/auto_settle.py b/python/examples/auto_settle.py index 301708b..8d38689 100644 --- a/python/examples/auto_settle.py +++ b/python/examples/auto_settle.py @@ -41,8 +41,8 @@ async def auto_settle_loop(client: ERC8183Client, poll_interval: int = 15): if job.status == JobStatus.SUBMITTED: now = int(time.time()) - - # We use expiredAt as the universal escape hatch, but rely on + + # We use expiredAt as the universal escape hatch, but rely on # router.settle's internal check for the dispute window. if job.expired_at <= now: logger.debug(f"Job {job_id} expired, waiting for claimRefund flow.") @@ -53,11 +53,13 @@ async def auto_settle_loop(client: ERC8183Client, poll_interval: int = 15): # client.settle() delegates to router.settle(), which pulls the verdict. # If the dispute window hasn't passed, it will revert. result = await asyncio.to_thread(client.settle, job_id) - tx_hash = result.get('transactionHash') + tx_hash = result.get("transactionHash") logger.info(f"Successfully settled Job {job_id}. Tx: {tx_hash}") except Exception as e: # Expected to fail if the dispute window is still open - logger.debug(f"Cannot settle Job {job_id} yet (likely dispute window open): {e}") + logger.debug( + f"Cannot settle Job {job_id} yet (likely dispute window open): {e}" + ) else: # Optional: log other statuses at debug level to avoid console spam pass @@ -82,12 +84,13 @@ async def main(): # persist=True is default, allowing the keystore to be saved for future runs wallet = EVMWalletProvider( - password=wallet_password, - private_key=private_key, + password=wallet_password, + private_key=private_key, ) client = ERC8183Client(wallet, network=network) await auto_settle_loop(client) + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/examples/client/_helpers.py b/python/examples/client/_helpers.py index 07eca19..28eef0a 100644 --- a/python/examples/client/_helpers.py +++ b/python/examples/client/_helpers.py @@ -11,9 +11,9 @@ from dotenv import dotenv_values import bnbagent +from bnbagent.config import resolve_network from bnbagent.erc8183 import ERC8183Client from bnbagent.wallets import TWAK_CHAIN_FOR_NETWORK, EVMWalletProvider, TWAKProvider -from bnbagent.config import resolve_network ROOT = Path(__file__).resolve().parent @@ -40,8 +40,8 @@ def _require_env(name: str) -> str: @dataclass(frozen=True) class Settings: network: str - wallet_kind: str # "evm" (default) | "twak" — switches the CLIENT wallet only - client_pk: str | None # required for evm; unused when wallet_kind=twak + wallet_kind: str # "evm" (default) | "twak" — switches the CLIENT wallet only + client_pk: str | None # required for evm; unused when wallet_kind=twak provider_address: str provider_pk: str | None voter_pk: str | None @@ -84,7 +84,7 @@ def _demo_network(network: str): # Prefer the NodeReal RPC from voter/.env — it has a higher block-range # limit (5 000 blocks per get_logs) vs the public default endpoint. voter_env = dotenv_values(ROOT.parent / "voter" / ".env") - rpc_url = voter_env.get("RPC_URL") + rpc_url = voter_env.get("RPC_URL") if rpc_url: return dataclasses.replace(resolve_network(network), rpc_url=rpc_url) return network diff --git a/python/examples/client/create_and_verify.py b/python/examples/client/create_and_verify.py index 8c676c5..22a374d 100644 --- a/python/examples/client/create_and_verify.py +++ b/python/examples/client/create_and_verify.py @@ -27,8 +27,8 @@ from _helpers import banner, expiry_for, load_settings, make_primary_client -POLL_INTERVAL = 5 # seconds between status polls -POLL_TIMEOUT = 240 # allow for one full poll cycle + on-chain submission +POLL_INTERVAL = 5 # seconds between status polls +POLL_TIMEOUT = 240 # allow for one full poll cycle + on-chain submission def main() -> None: @@ -49,7 +49,7 @@ def main() -> None: ) decimals = client.token_decimals() - budget = 1 * (10 ** decimals) + budget = 1 * (10**decimals) # --- 1. Create + register + fund ---------------------------------------- expired_at = expiry_for(client) @@ -72,6 +72,7 @@ def main() -> None: # --- 2. Wait for the agent's funded-poll loop to pick up the job -------- from bnbagent.erc8183 import JobStatus + print(f"\n[client] waiting for agent to submit (up to {POLL_TIMEOUT}s)...") deadline = time.time() + POLL_TIMEOUT job = client.get_job(job_id) @@ -87,13 +88,15 @@ def main() -> None: # --- 4. Verify manifest hash via IPFS ----------------------------------- import httpx + deliverable_url = client.get_deliverable_url(job_id) print(f" deliverableUrl: {deliverable_url}") if deliverable_url and deliverable_url.startswith("ipfs://"): - cid = deliverable_url[len("ipfs://"):] + cid = deliverable_url[len("ipfs://") :] gateway_url = f"https://gateway.pinata.cloud/ipfs/{cid}" print(f"\n[client] fetching manifest from IPFS: {gateway_url}") from bnbagent.erc8183.schema import DeliverableManifest + try: fetch = httpx.get(gateway_url, timeout=15) fetch.raise_for_status() @@ -114,7 +117,7 @@ def main() -> None: client.dispute(job_id) print(f"[client] dispute({job_id}) OK") print(f"\n job {job_id} is now DISPUTED") - print(f" → voter can review and vote in examples/voter/watch.py") + print(" → voter can review and vote in examples/voter/watch.py") print(f" → after quorum, anyone can call settle({job_id})") else: print( diff --git a/python/examples/client/dispute_reject.py b/python/examples/client/dispute_reject.py index 89772ca..957a885 100644 --- a/python/examples/client/dispute_reject.py +++ b/python/examples/client/dispute_reject.py @@ -10,7 +10,7 @@ from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client -from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION +from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus def main() -> None: @@ -20,7 +20,7 @@ def main() -> None: banner("DISPUTE REJECT — client disputes, voter rejects") decimals = client.token_decimals() - budget = 1 * (10 ** decimals) + budget = 1 * (10**decimals) expired_at = expiry_for(client) res = client.create_job( @@ -49,11 +49,15 @@ def main() -> None: "router": provider.router.address, "policy": provider.policy.address, }, - response={"content": f"dispute test result for job {job_id}", "content_type": "text/plain"}, + response={ + "content": f"dispute test result for job {job_id}", + "content_type": "text/plain", + }, ) # In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL. # deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json") - deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only + # Placeholder: these scripts test on-chain flow only. + deliverable_url = "https://example.invalid/manifest.json" provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url}) print("[provider] submit OK") diff --git a/python/examples/client/gen_job_report.py b/python/examples/client/gen_job_report.py index d909368..ccf0a2a 100644 --- a/python/examples/client/gen_job_report.py +++ b/python/examples/client/gen_job_report.py @@ -16,43 +16,50 @@ import urllib.request from pathlib import Path -from dotenv import load_dotenv, dotenv_values +from dotenv import dotenv_values, load_dotenv ROOT = Path(__file__).resolve().parent load_dotenv(ROOT / ".env") -from bnbagent.erc8183 import ERC8183Client, JobStatus, Verdict -from bnbagent.erc8183.types import REASON_APPROVED, REASON_REJECTED -from bnbagent.wallets import EVMWalletProvider -from bnbagent.config import resolve_network +from bnbagent.config import resolve_network # noqa: E402 +from bnbagent.erc8183 import ERC8183Client, JobStatus, Verdict # noqa: E402 +from bnbagent.erc8183.types import REASON_APPROVED, REASON_REJECTED # noqa: E402 +from bnbagent.wallets import EVMWalletProvider # noqa: E402 SCAN_TX = "https://testnet.bscscan.com/tx/0x" EMOJI: dict[str, str] = { - "JobCreated": "🆕", - "BudgetSet": "💰", - "ProviderSet": "👤", - "JobRegistered": "📋", - "JobFunded": "💵", - "JobSubmitted": "📤", - "JobInitialised": "🗂", - "Disputed": "⚖️", - "VoteCast": "🗳", - "QuorumReached": "✅", - "JobSettled": "🏁", - "JobFinalised": "🏁", - "JobCompleted": "✔️", - "JobRejected": "❌", - "JobExpired": "⏰", - "Refunded": "↩️", + "JobCreated": "🆕", + "BudgetSet": "💰", + "ProviderSet": "👤", + "JobRegistered": "📋", + "JobFunded": "💵", + "JobSubmitted": "📤", + "JobInitialised": "🗂", + "Disputed": "⚖️", + "VoteCast": "🗳", + "QuorumReached": "✅", + "JobSettled": "🏁", + "JobFinalised": "🏁", + "JobCompleted": "✔️", + "JobRejected": "❌", + "JobExpired": "⏰", + "Refunded": "↩️", "PaymentReleased": "💸", } EVENT_MAP_KEYS = { "commerce": [ - "JobCreated", "BudgetSet", "ProviderSet", "JobFunded", - "JobSubmitted", "JobCompleted", "JobRejected", "JobExpired", - "Refunded", "PaymentReleased", + "JobCreated", + "BudgetSet", + "ProviderSet", + "JobFunded", + "JobSubmitted", + "JobCompleted", + "JobRejected", + "JobExpired", + "Refunded", + "PaymentReleased", ], "router": ["JobRegistered", "JobSettled", "JobFinalised"], "policy": ["Disputed", "JobInitialised", "VoteCast", "QuorumReached"], @@ -61,9 +68,9 @@ def _make_client() -> ERC8183Client: voter_env = dotenv_values(ROOT.parent / "voter" / ".env") - rpc_url = voter_env.get("RPC_URL") - network = os.environ.get("NETWORK", "bsc-testnet") - wallet = EVMWalletProvider( + rpc_url = voter_env.get("RPC_URL") + network = os.environ.get("NETWORK", "bsc-testnet") + wallet = EVMWalletProvider( password="demo", private_key=os.environ["PRIVATE_KEY"], persist=False, @@ -75,26 +82,28 @@ def _make_client() -> ERC8183Client: def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]: - w3 = client.commerce.w3 + w3 = client.commerce.w3 latest = w3.eth.block_number submit_block = client._resolve_submit_block(job_id) if submit_block is not None: from_block = max(0, submit_block - 5_000) - to_block = min(latest, submit_block + 5_000) + to_block = min(latest, submit_block + 5_000) else: from_block = max(0, latest - 1_000) - to_block = latest + to_block = latest contract_map = { "Commerce": client.commerce.contract, - "Router": client.router.contract, - "Policy": client.policy.contract, + "Router": client.router.contract, + "Policy": client.policy.contract, } events: list[dict] = [] for (contract_key, contract_label), contract in zip( - zip(EVENT_MAP_KEYS.keys(), contract_map.keys()), contract_map.values() + zip(EVENT_MAP_KEYS.keys(), contract_map.keys(), strict=True), + contract_map.values(), + strict=True, ): for ev_name in EVENT_MAP_KEYS[contract_key]: try: @@ -104,13 +113,15 @@ def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]: argument_filters={"jobId": job_id}, ) for log in logs: - events.append({ - "block": log["blockNumber"], - "tx": log["transactionHash"].hex(), - "contract": contract_label, - "name": ev_name, - "args": dict(log["args"]), - }) + events.append( + { + "block": log["blockNumber"], + "tx": log["transactionHash"].hex(), + "contract": contract_label, + "name": ev_name, + "args": dict(log["args"]), + } + ) except Exception as exc: print(f" [warn] {contract_label}.{ev_name}: {exc}", file=sys.stderr) @@ -120,17 +131,22 @@ def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]: _block_ts_cache: dict[int, int] = {} + def _block_ts(w3, blk: int) -> int: if blk not in _block_ts_cache: _block_ts_cache[blk] = w3.eth.get_block(blk)["timestamp"] return _block_ts_cache[blk] + def _utc(ts: int) -> str: - return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) _receipt_cache: dict[str, str] = {} + def _caller(w3, tx_hash: str) -> str: if tx_hash not in _receipt_cache: try: @@ -148,14 +164,14 @@ def _resolve_deliverable(client: ERC8183Client, job_id: int) -> tuple[str | None gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/") if url.startswith("ipfs://"): - fetch_url = gateway.rstrip("/") + "/" + url[len("ipfs://"):] + fetch_url = gateway.rstrip("/") + "/" + url[len("ipfs://") :] elif url.startswith(("http://", "https://")): fetch_url = url else: return url, None try: - raw = urllib.request.urlopen(fetch_url, timeout=15).read() + raw = urllib.request.urlopen(fetch_url, timeout=15).read() text = raw.decode("utf-8", errors="replace") if len(text) > 2048: text = text[:2048] + "\n... (truncated — see URL above for full content)" @@ -169,11 +185,35 @@ def _compute_fund_flow(events: list[dict], commerce_addr: str) -> list[dict]: for e in events: a = e["args"] if e["name"] == "JobFunded": - flows.append({"dir": "Escrowed", "from": a["client"], "to": commerce_addr, "amount": a["amount"], "event": "JobFunded"}) + flows.append( + { + "dir": "Escrowed", + "from": a["client"], + "to": commerce_addr, + "amount": a["amount"], + "event": "JobFunded", + } + ) elif e["name"] == "Refunded": - flows.append({"dir": "Refunded", "from": commerce_addr, "to": a["client"], "amount": a["amount"], "event": "Refunded"}) + flows.append( + { + "dir": "Refunded", + "from": commerce_addr, + "to": a["client"], + "amount": a["amount"], + "event": "Refunded", + } + ) elif e["name"] == "PaymentReleased": - flows.append({"dir": "Released", "from": commerce_addr, "to": a["provider"], "amount": a["amount"], "event": "PaymentReleased"}) + flows.append( + { + "dir": "Released", + "from": commerce_addr, + "to": a["provider"], + "amount": a["amount"], + "event": "PaymentReleased", + } + ) return flows @@ -215,15 +255,15 @@ def _render( response_body: str | None, fund_flows: list[dict], ) -> str: - w3 = client.commerce.w3 + w3 = client.commerce.w3 decimals = client.token_decimals() - symbol = client.token_symbol() + symbol = client.token_symbol() chain_id = client.network.chain_id - now = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + now = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - rv = client.policy.reject_votes(job_id) + rv = client.policy.reject_votes(job_id) quorum = client.policy.vote_quorum() - disp = client.policy.disputed(job_id) + disp = client.policy.disputed(job_id) try: verdict, _ = client.policy.check(job_id) verdict_str = verdict.name @@ -231,21 +271,22 @@ def _render( verdict_str = "N/A" exp_utc = _utc(job.expired_at) - now_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) + now_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) if now_ts < job.expired_at: exp_rel = f"expires in {(job.expired_at - now_ts) // 60} min" else: exp_rel = f"expired {(now_ts - job.expired_at) // 60} min ago" L: list[str] = [] - def l(s: str = "") -> None: + + def l(s: str = "") -> None: # noqa: E743 - concise local Markdown line builder L.append(s) l(f"# Job {job_id} — On-chain Timeline Report") l() l(f"> Generated: {now} ") l(f"> Network: BSC Testnet (chain_id={chain_id}) ") - l(f"> Explorer: https://testnet.bscscan.com") + l("> Explorer: https://testnet.bscscan.com") l() l("---") l() @@ -276,7 +317,7 @@ def l(s: str = "") -> None: l(f"| rejectVotes | `{rv} / {quorum}` |") l(f"| verdict | `{verdict_str}` |") l(f"| disputed | `{disp}` |") - if job.deliverable and job.deliverable != b'\x00' * 32: + if job.deliverable and job.deliverable != b"\x00" * 32: l(f"| deliverable hash (on-chain) | `0x{job.deliverable.hex()}` |") l() l("---") @@ -285,11 +326,11 @@ def l(s: str = "") -> None: l("## Timeline") l() for ev in events: - blk = ev["block"] - tx = ev["tx"] - utc = _utc(_block_ts(w3, blk)) - emoji = EMOJI.get(ev["name"], "📌") - c_from = _caller(w3, tx) + blk = ev["block"] + tx = ev["tx"] + utc = _utc(_block_ts(w3, blk)) + emoji = EMOJI.get(ev["name"], "📌") + c_from = _caller(w3, tx) l(f"### {emoji} `{ev['name']}` — {ev['contract']} — block {blk} — {utc}") l() @@ -313,11 +354,11 @@ def l(s: str = "") -> None: if deliverable_url: l(f"- **deliverable_url**: `{deliverable_url}`") if deliverable_url.startswith("ipfs://"): - cid = deliverable_url[len("ipfs://"):] + cid = deliverable_url[len("ipfs://") :] gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/") l(f"- **CID**: `{cid}`") l(f"- **Gateway URL**: {gateway.rstrip('/')}/{cid}") - if job.deliverable and job.deliverable != b'\x00' * 32: + if job.deliverable and job.deliverable != b"\x00" * 32: l(f"- **Manifest hash (on-chain)**: `0x{job.deliverable.hex()}`") l() if response_body: @@ -331,7 +372,10 @@ def l(s: str = "") -> None: else: l("> Response body could not be fetched.") else: - l("> deliverable_url not found (job may not be submitted yet, or event is outside the scan window).") + l( + "> deliverable_url not found (job may not be submitted yet, " + "or event is outside the scan window)." + ) l() l("---") l() @@ -347,8 +391,10 @@ def l(s: str = "") -> None: l() provider_received = sum(f["amount"] for f in fund_flows if f["event"] == "PaymentReleased") - client_refunded = sum(f["amount"] for f in fund_flows if f["event"] == "Refunded") - client_spent = sum(f["amount"] for f in fund_flows if f["event"] == "JobFunded") - client_refunded + client_refunded = sum(f["amount"] for f in fund_flows if f["event"] == "Refunded") + client_spent = ( + sum(f["amount"] for f in fund_flows if f["event"] == "JobFunded") - client_refunded + ) l(f"> **Provider received**: {provider_received / 10**decimals:.4f} {symbol} ") l(f"> **Client net cost**: {client_spent / 10**decimals:.4f} {symbol}") @@ -377,7 +423,7 @@ def l(s: str = "") -> None: def main(job_id: int) -> None: print(f"Generating job-{job_id}-report.md ...") client = _make_client() - job = client.get_job(job_id) + job = client.get_job(job_id) print(f" job {job_id}: status={job.status.name} provider={job.provider}") print(" Collecting on-chain events ...") @@ -390,7 +436,7 @@ def main(job_id: int) -> None: fund_flows = _compute_fund_flow(events, client.commerce.address) - md = _render(client, job_id, job, events, deliverable_url, response_body, fund_flows) + md = _render(client, job_id, job, events, deliverable_url, response_body, fund_flows) out = ROOT / f"job-{job_id}-report.md" out.write_text(md, encoding="utf-8") print(f"Done: {out}") diff --git a/python/examples/client/happy.py b/python/examples/client/happy.py index 52e35f4..3f7ce1d 100644 --- a/python/examples/client/happy.py +++ b/python/examples/client/happy.py @@ -10,7 +10,7 @@ from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client -from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION +from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus def main() -> None: @@ -20,7 +20,7 @@ def main() -> None: banner("HAPPY — create + fund + submit + settle") decimals = client.token_decimals() - budget = 1 * (10 ** decimals) # 1 token + budget = 1 * (10**decimals) # 1 token expired_at = expiry_for(client) # disputeWindow + 10 min slack res = client.create_job( @@ -61,7 +61,8 @@ def main() -> None: ) # In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL. # deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json") - deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only + # Placeholder: these scripts test on-chain flow only. + deliverable_url = "https://example.invalid/manifest.json" provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url}) print("[provider] submit OK (Funded -> Submitted)") diff --git a/python/examples/client/stalemate_expire.py b/python/examples/client/stalemate_expire.py index 9c8d2bb..1539a7c 100644 --- a/python/examples/client/stalemate_expire.py +++ b/python/examples/client/stalemate_expire.py @@ -17,7 +17,7 @@ from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client -from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION +from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus def main() -> None: @@ -27,7 +27,7 @@ def main() -> None: banner("STALEMATE — dispute without quorum, refund at expiry") decimals = client.token_decimals() - budget = 1 * (10 ** decimals) + budget = 1 * (10**decimals) # Smallest expiry that still admits a valid submit: disputeWindow + 1 min. expired_at = expiry_for(client, slack_minutes=1) @@ -56,11 +56,15 @@ def main() -> None: "router": provider.router.address, "policy": provider.policy.address, }, - response={"content": f"stalemate test result for job {job_id}", "content_type": "text/plain"}, + response={ + "content": f"stalemate test result for job {job_id}", + "content_type": "text/plain", + }, ) # In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL. # deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json") - deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only + # Placeholder: these scripts test on-chain flow only. + deliverable_url = "https://example.invalid/manifest.json" provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url}) print("[provider] submit OK") diff --git a/python/examples/client/zero_price.py b/python/examples/client/zero_price.py index 19cca3b..57c14d8 100644 --- a/python/examples/client/zero_price.py +++ b/python/examples/client/zero_price.py @@ -21,15 +21,14 @@ from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client -from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION +from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus def main() -> None: s = load_settings() if not s.provider_pk: raise RuntimeError( - "PROVIDER_PRIVATE_KEY is required for the zero-price flow: " - "submit is provider-only." + "PROVIDER_PRIVATE_KEY is required for the zero-price flow: submit is provider-only." ) client = make_primary_client(s) # EVM or twak, per WALLET_KIND provider = make_client(s.provider_pk, s.network) @@ -71,7 +70,8 @@ def main() -> None: }, response={"content": f"zero-price result for job {job_id}", "content_type": "text/plain"}, ) - deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only + # Placeholder: these scripts test on-chain flow only. + deliverable_url = "https://example.invalid/manifest.json" provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url}) print("[provider] submit OK (Funded -> Submitted)") diff --git a/python/examples/security/e2e.py b/python/examples/security/e2e.py index 75dae5f..d168ea7 100644 --- a/python/examples/security/e2e.py +++ b/python/examples/security/e2e.py @@ -40,13 +40,10 @@ ) from bnbagent.x402 import ( X402AmountExceededError, - X402PolicyError, X402RecipientMismatchError, ) -logging.basicConfig( - level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s" -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s") log = logging.getLogger("security_e2e") # ── Fixtures ─────────────────────────────────────────────────────────────── @@ -56,8 +53,12 @@ # if you need a deterministic key for a specific repro. PK = os.environ.get("E2E_PRIVATE_KEY") or Account.create().key.hex() U_TESTNET = get_address(BSC_TESTNET_CHAIN_ID).payment_token -log.info("U testnet address: %s (name=%r version=%r)", - U_TESTNET, PAYMENT_TOKEN_EIP712_NAME, PAYMENT_TOKEN_EIP712_VERSION) +log.info( + "U testnet address: %s (name=%r version=%r)", + U_TESTNET, + PAYMENT_TOKEN_EIP712_NAME, + PAYMENT_TOKEN_EIP712_VERSION, +) EIP712_DOMAIN_FIELDS = [ {"name": "name", "type": "string"}, @@ -126,7 +127,9 @@ def assert_1_default_signs_u_token_and_round_trips(tmpdir: str) -> None: # Round-trip recover message_types = {k: v for k, v in types.items() if k != "EIP712Domain"} signable = encode_typed_data( - domain_data=domain, message_types=message_types, message_data=msg, + domain_data=domain, + message_types=message_types, + message_data=msg, ) recovered = Account.recover_message(signable, signature=signed["signature"]) assert recovered == wallet.address, (recovered, wallet.address) @@ -200,7 +203,10 @@ def assert_5_x402signer_rejects_overvalue(tmpdir: str) -> None: msg = twa_message(wallet, value=2_000_000) try: signer.sign_payment( - domain=domain, types=types, message=msg, expected_to=msg["to"], + domain=domain, + types=types, + message=msg, + expected_to=msg["to"], ) except X402AmountExceededError as e: log.info(" → X402AmountExceededError as expected: %s", e) @@ -218,7 +224,9 @@ def assert_6_x402signer_rejects_recipient_mismatch(tmpdir: str) -> None: msg = twa_message(wallet, to="0x" + "b" * 40) try: signer.sign_payment( - domain=domain, types=types, message=msg, + domain=domain, + types=types, + message=msg, expected_to="0x" + "9" * 40, # different! ) except X402RecipientMismatchError as e: diff --git a/python/examples/twak/quickstart.py b/python/examples/twak/quickstart.py index b257346..24fd703 100644 --- a/python/examples/twak/quickstart.py +++ b/python/examples/twak/quickstart.py @@ -110,8 +110,9 @@ def create_throwaway_wallet(provider: TWAKProvider, home: Path) -> str: to resolve it from TWAK_WALLET_PASSWORD / the keychain. The shipped CLI, however, hard-requires ``--password`` on argv for ``wallet create`` (env resolution only covers *unlock*; gaps S-8, re-verified unchanged on - v0.19.0) — so the SDK call currently fails. We try it first (so this script self-heals when either - side fixes the mismatch) and fall back to driving the CLI directly. + v0.19.0) — so the SDK call currently fails. We try it first (so this script + self-heals when either side fixes the mismatch) and fall back to driving + the CLI directly. The fallback puts the throwaway password on argv — acceptable for a demo wallet in a tempdir, never for a real one. """ @@ -124,9 +125,14 @@ def create_throwaway_wallet(provider: TWAKProvider, home: Path) -> str: print(" falling back to a direct CLI create — demo wallet only)") proc = subprocess.run( # noqa: S603 - fixed arg list, no shell [ - TWAK_BIN, "wallet", "create", - "--password", os.environ["TWAK_WALLET_PASSWORD"], - "--no-keychain", "--skip-password-check", "--json", + TWAK_BIN, + "wallet", + "create", + "--password", + os.environ["TWAK_WALLET_PASSWORD"], + "--no-keychain", + "--skip-password-check", + "--json", ], capture_output=True, text=True, @@ -188,17 +194,11 @@ def step_b_capabilities(twak: TWAKProvider) -> None: evm = EVMWalletProvider( password="quickstart-demo", private_key=Account.create().key.hex(), # ephemeral, never broadcast - persist=False, # in-memory only, no keystore file + persist=False, # in-memory only, no keystore file ) print(f"evm capabilities: {sorted(evm.capabilities())}") - print( - " evm-only: " - f"{sorted(evm.capabilities() - twak.capabilities())}" - ) - print( - " twak-only: " - f"{sorted(twak.capabilities() - evm.capabilities())}" - ) + print(f" evm-only: {sorted(evm.capabilities() - twak.capabilities())}") + print(f" twak-only: {sorted(twak.capabilities() - evm.capabilities())}") print( "Reading: twak signs nothing raw (no sign.transaction/typed_data) but\n" "broadcasts its own fixed intent menu; EVM signs anything but needs\n" @@ -250,9 +250,7 @@ def step_c_guard_rails(twak: TWAKProvider) -> None: # the message bytes. msg = "hello from the bnbagent twak quickstart" signed = twak.sign_message(msg) - recovered = Account.recover_message( - encode_defunct(text=msg), signature=signed["signature"] - ) + recovered = Account.recover_message(encode_defunct(text=msg), signature=signed["signature"]) assert recovered.lower() == twak.address.lower(), (recovered, twak.address) print("4. sign_message round-trip:") print(f" messageHash = {signed['messageHash']}") diff --git a/python/examples/voter/vote_reject.py b/python/examples/voter/vote_reject.py index 10e1166..068fab2 100644 --- a/python/examples/voter/vote_reject.py +++ b/python/examples/voter/vote_reject.py @@ -17,11 +17,11 @@ import time from pathlib import Path -from dotenv import load_dotenv, dotenv_values +from dotenv import dotenv_values, load_dotenv +from bnbagent.config import resolve_network from bnbagent.erc8183 import ERC8183Client from bnbagent.wallets import EVMWalletProvider -from bnbagent.config import resolve_network ROOT = Path(__file__).resolve().parent @@ -45,7 +45,7 @@ def main() -> int: network = os.environ.get("NETWORK", "bsc-testnet") rpc_url = dotenv_values(ROOT / ".env").get("RPC_URL") - wallet = EVMWalletProvider(password="example", private_key=pk, persist=False) + wallet = EVMWalletProvider(password="example", private_key=pk, persist=False) if rpc_url: nc = dataclasses.replace(resolve_network(network), rpc_url=rpc_url) erc8183 = ERC8183Client(wallet, network=nc) @@ -58,7 +58,9 @@ def main() -> int: print(f"{voter} is NOT a whitelisted voter on {erc8183.policy.address}", file=sys.stderr) return 1 if not erc8183.policy.disputed(job_id): - print(f"jobId={job_id} has not been disputed yet; voteReject would revert", file=sys.stderr) + print( + f"jobId={job_id} has not been disputed yet; voteReject would revert", file=sys.stderr + ) return 1 if erc8183.policy.has_voted(job_id, voter): print(f"{voter} already voted on jobId={job_id}", file=sys.stderr) @@ -80,7 +82,10 @@ def main() -> int: time.sleep(2) if new_total >= quorum: - print(f"[voter] quorum reached ({new_total}/{quorum}); any settler can now call router.settle({job_id})") + print( + f"[voter] quorum reached ({new_total}/{quorum}); " + f"any settler can now call router.settle({job_id})" + ) else: print(f"[voter] current reject votes: {new_total}/{quorum} — still below quorum") return 0 diff --git a/python/examples/voter/watch.py b/python/examples/voter/watch.py index feb37e1..8b63d8d 100644 --- a/python/examples/voter/watch.py +++ b/python/examples/voter/watch.py @@ -36,7 +36,7 @@ def fetch_manifest(deliverable_url: str, gateway_url: str) -> DeliverableManifes """Download and parse a DeliverableManifest from IPFS.""" try: if deliverable_url.startswith("ipfs://"): - cid = deliverable_url[len("ipfs://"):] + cid = deliverable_url[len("ipfs://") :] url = f"{gateway_url.rstrip('/')}/{cid}" else: url = deliverable_url @@ -48,11 +48,13 @@ def fetch_manifest(deliverable_url: str, gateway_url: str) -> DeliverableManifes return None -def handle_quorum_reached(erc8183: ERC8183Client, job_id: int, reject_votes: int, quorum: int) -> None: +def handle_quorum_reached( + erc8183: ERC8183Client, job_id: int, reject_votes: int, quorum: int +) -> None: """Called when VoteCast shows rejectVotes >= quorum — settle and print result.""" - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f" QUORUM REACHED job_id={job_id} ({reject_votes}/{quorum} reject votes)") - print(f"{'='*60}") + print(f"{'=' * 60}") print(f" settling job {job_id}...") try: erc8183.settle(job_id) @@ -69,9 +71,9 @@ def handle_disputed_job( hint_block: int | None = None, ) -> None: """Show job details and prompt voter to reject or skip.""" - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f" DISPUTED job_id={job_id}") - print(f"{'='*60}") + print(f"{'=' * 60}") already_voted = erc8183.policy.has_voted(job_id, voter) if already_voted: @@ -121,13 +123,13 @@ def handle_disputed_job( choice = input("\n [r]eject [s]kip > ").strip().lower() except (EOFError, KeyboardInterrupt): print("\nstopped.") - raise SystemExit(0) + raise SystemExit(0) from None if choice == "r": print(f" casting voteReject({job_id})...") erc8183.vote_reject(job_id) print(f" voteReject({job_id}) submitted ✓") - print(f" (waiting for VoteCast event to check quorum...)") + print(" (waiting for VoteCast event to check quorum...)") else: print(f" skipped job {job_id}") @@ -143,15 +145,16 @@ def main() -> None: gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/") from bnbagent.config import resolve_network + nc = resolve_network(network) wallet = EVMWalletProvider(password="example", private_key=pk, persist=False) - erc8183 = ERC8183Client(wallet, network=nc) - voter = erc8183.address + erc8183 = ERC8183Client(wallet, network=nc) + voter = erc8183.address quorum = erc8183.policy.vote_quorum() - print(f"Voter watch loop") + print("Voter watch loop") print(f" network : {nc.name}") print(f" rpc : {nc.rpc_url}") print(f" policy : {erc8183.policy.address}") @@ -159,7 +162,7 @@ def main() -> None: print(f" listed : {erc8183.policy.is_voter(voter)}") print(f" quorum : {quorum}") print(f" gateway : {gateway}") - print(f"\nWatching for Disputed / VoteCast events (Ctrl+C to stop)...\n") + print("\nWatching for Disputed / VoteCast events (Ctrl+C to stop)...\n") seen_disputed: set[int] = set() settled: set[int] = set() @@ -179,7 +182,9 @@ def main() -> None: print(f"[{ts}] Disputed event — jobId={job_id}") if job_id not in seen_disputed: seen_disputed.add(job_id) - handle_disputed_job(erc8183, job_id, voter, gateway, hint_block=log["blockNumber"]) + handle_disputed_job( + erc8183, job_id, voter, gateway, hint_block=log["blockNumber"] + ) # --- VoteCast events ------------------------------------------------ vote_logs = erc8183.policy.contract.events.VoteCast().get_logs( @@ -187,11 +192,14 @@ def main() -> None: to_block=head, ) for log in vote_logs: - job_id = log["args"]["jobId"] + job_id = log["args"]["jobId"] reject_votes = log["args"]["rejectVotes"] - caster = log["args"]["voter"] + caster = log["args"]["voter"] ts = time.strftime("%H:%M:%S") - print(f"[{ts}] VoteCast — jobId={job_id} rejectVotes={reject_votes}/{quorum} by={caster}") + print( + f"[{ts}] VoteCast — jobId={job_id} " + f"rejectVotes={reject_votes}/{quorum} by={caster}" + ) if reject_votes >= quorum and job_id not in settled: settled.add(job_id) handle_quorum_reached(erc8183, job_id, reject_votes, quorum) diff --git a/python/examples/x402/buyer_demo.py b/python/examples/x402/buyer_demo.py index 4379fe6..ae30c3a 100644 --- a/python/examples/x402/buyer_demo.py +++ b/python/examples/x402/buyer_demo.py @@ -50,9 +50,7 @@ get_address, ) -logging.basicConfig( - level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s" -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s") log = logging.getLogger("x402_buyer_demo") # ── Fixtures ────────────────────────────────────────────────────────────── @@ -63,8 +61,8 @@ U_TESTNET = get_address(BSC_TESTNET_CHAIN_ID).payment_token NETWORK_ID = f"eip155:{BSC_TESTNET_CHAIN_ID}" -PAY_TO = "0x" + "be" * 20 # Mock beneficiary (server-controlled in real life) -PRICE_BASE_UNITS = 100_000 # 0.1 U at 6 decimals — same shape as a real x402 listing +PAY_TO = "0x" + "be" * 20 # Mock beneficiary (server-controlled in real life) +PRICE_BASE_UNITS = 100_000 # 0.1 U at 6 decimals — same shape as a real x402 listing SECRET_PAYLOAD = "secret payload" # EIP-712 schema fields — identical to the production U-token EIP-3009 domain. @@ -198,9 +196,7 @@ def _build_twa_message(from_addr: str, accept: dict[str, Any]) -> dict[str, Any] } -def _build_payment_envelope( - accept: dict[str, Any], msg: dict[str, Any], signature: str -) -> str: +def _build_payment_envelope(accept: dict[str, Any], msg: dict[str, Any], signature: str) -> str: """Encode the X-PAYMENT envelope per x402 v2. The envelope is base64(json) so it travels safely in an HTTP header. @@ -251,8 +247,13 @@ def main() -> int: return 1 log.info("step 1: GET %s → 402 (challenge received)", url) accept = body["accepts"][0] - log.info(" challenge: pay %s base units of %s to %s on %s", - accept["amount"], accept["asset"], accept["payTo"], accept["network"]) + log.info( + " challenge: pay %s base units of %s to %s on %s", + accept["amount"], + accept["asset"], + accept["payTo"], + accept["network"], + ) # 4. Construct the EIP-712 payload that satisfies the challenge. domain = { @@ -283,7 +284,9 @@ def main() -> int: # ``signature`` may come back as HexBytes; normalize to a 0x-hex # string for the JSON envelope. raw_sig = signed["signature"] - sig = raw_sig.hex() if hasattr(raw_sig, "hex") and not isinstance(raw_sig, str) else raw_sig + sig = ( + raw_sig.hex() if hasattr(raw_sig, "hex") and not isinstance(raw_sig, str) else raw_sig + ) if not sig.startswith("0x"): sig = "0x" + sig log.info("step 2: signed TransferWithAuthorization (sig=%s…%s)", sig[:10], sig[-6:]) diff --git a/python/tests/test_erc8004_contract.py b/python/tests/test_erc8004_contract.py index 2245142..653e02a 100644 --- a/python/tests/test_erc8004_contract.py +++ b/python/tests/test_erc8004_contract.py @@ -76,9 +76,14 @@ def test_raises_on_receipt_status_zero(self, caplog): fn = MagicMock() fn.estimate_gas.return_value = 100_000 fn.build_transaction.return_value = { - "from": "0xDeadBeef", "to": "0x1234", - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": "0xDeadBeef", + "to": "0x1234", + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } web3.eth.get_transaction_count.return_value = 1 web3.eth.gas_price = 3_000_000_000 @@ -117,9 +122,14 @@ def test_success_receipt_returns_normally(self): fn = MagicMock() fn.estimate_gas.return_value = 100_000 fn.build_transaction.return_value = { - "from": "0xDeadBeef", "to": "0x1234", - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": "0xDeadBeef", + "to": "0x1234", + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } web3.eth.get_transaction_count.return_value = 1 web3.eth.gas_price = 3_000_000_000 @@ -156,9 +166,14 @@ def _setup(self, web3=None): fn = MagicMock() fn.estimate_gas.return_value = 100_000 fn.build_transaction.return_value = { - "from": "0xDeadBeef", "to": "0x1234", - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": "0xDeadBeef", + "to": "0x1234", + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } web3.eth.get_transaction_count.return_value = 1 web3.eth.gas_price = 3_000_000_000 @@ -174,7 +189,9 @@ def _setup(self, web3=None): ok_receipt = Mock() ok_receipt.__getitem__ = lambda s, k: { - "status": 1, "blockNumber": 1, "gasUsed": 1, + "status": 1, + "blockNumber": 1, + "gasUsed": 1, "transactionHash": sent_hash, }[k] web3.eth.wait_for_transaction_receipt.return_value = ok_receipt @@ -208,9 +225,6 @@ def slow_call(params): web3.eth.call.side_effect = slow_call - # Patch ThreadPoolExecutor so TimeoutError propagates correctly - original_executor = concurrent.futures.ThreadPoolExecutor - class ImmediateTimeoutExecutor: def __init__(self, **kwargs): pass @@ -226,7 +240,9 @@ def submit(self, fn, *args, **kwargs): f.set_exception(concurrent.futures.TimeoutError()) return f - with patch("bnbagent.wallets.local_executor._cf.ThreadPoolExecutor", ImmediateTimeoutExecutor): + with patch( + "bnbagent.wallets.local_executor._cf.ThreadPoolExecutor", ImmediateTimeoutExecutor + ): result = ci._execute_transaction(fn, description="timeout-test") web3.eth.send_raw_transaction.assert_called_once() @@ -243,9 +259,14 @@ def test_register_agent_raises_on_revert(self): register_fn = MagicMock() register_fn.estimate_gas.return_value = 100_000 register_fn.build_transaction.return_value = { - "from": "0xDeadBeef", "to": "0x1234", - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": "0xDeadBeef", + "to": "0x1234", + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } ci.contract = MagicMock() ci.contract.functions.register.return_value = register_fn @@ -265,7 +286,9 @@ def test_register_agent_raises_on_revert(self): revert_receipt = Mock() revert_receipt.__getitem__ = lambda s, k: { - "status": 0, "blockNumber": 999, "gasUsed": 21000, + "status": 0, + "blockNumber": 999, + "gasUsed": 21000, "transactionHash": sent_hash, }[k] web3.eth.wait_for_transaction_receipt.return_value = revert_receipt @@ -282,9 +305,14 @@ def _setup_broadcast(self, web3, sent_hash=b"\xcd" * 32): fn = MagicMock() fn.estimate_gas.return_value = 100_000 fn.build_transaction.return_value = { - "from": "0xDeadBeef", "to": "0x1234", - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": "0xDeadBeef", + "to": "0x1234", + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } web3.eth.get_transaction_count.return_value = 1 web3.eth.gas_price = 3_000_000_000 @@ -343,9 +371,14 @@ def _setup_for_retry(self, web3=None): fn = MagicMock() fn.estimate_gas.return_value = 100_000 fn.build_transaction.return_value = { - "from": FAKE_ADDRESS, "to": FAKE_CONTRACT_ADDRESS, - "data": "0x", "value": 0, "gas": 100_000, - "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97, + "from": FAKE_ADDRESS, + "to": FAKE_CONTRACT_ADDRESS, + "data": "0x", + "value": 0, + "gas": 100_000, + "gasPrice": 3_000_000_000, + "nonce": 1, + "chainId": 97, } web3.eth.get_transaction_count.return_value = 1 web3.eth.gas_price = 3_000_000_000 @@ -360,7 +393,9 @@ def _setup_for_retry(self, web3=None): sent_hash = b"\xab" * 32 ok_receipt = Mock() ok_receipt.__getitem__ = lambda s, k: { - "status": 1, "blockNumber": 1, "gasUsed": 1, + "status": 1, + "blockNumber": 1, + "gasUsed": 1, "transactionHash": sent_hash, }[k] web3.eth.wait_for_transaction_receipt.return_value = ok_receipt diff --git a/python/tests/test_erc8183_client.py b/python/tests/test_erc8183_client.py index 9a1ae7d..a38b6f9 100644 --- a/python/tests/test_erc8183_client.py +++ b/python/tests/test_erc8183_client.py @@ -12,9 +12,9 @@ import pytest +from bnbagent.config import NetworkConfig from bnbagent.erc8183 import ERC8183Client from bnbagent.erc8183.client import DEFAULT_APPROVE_FLOOR_UNITS -from bnbagent.config import NetworkConfig from tests.conftest import FAKE_ADDRESS FAKE_COMMERCE = "0x" + "aa" * 20 @@ -43,10 +43,12 @@ def _mock_wallet() -> MagicMock: @pytest.fixture def facade(mock_web3): """``ERC8183Client`` wired against mock sub-clients (no real web3 traffic).""" - with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \ - patch("bnbagent.erc8183.client.CommerceClient") as mcc, \ - patch("bnbagent.erc8183.client.RouterClient") as mrc, \ - patch("bnbagent.erc8183.client.PolicyClient") as mpc: + with ( + patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), + patch("bnbagent.erc8183.client.CommerceClient") as mcc, + patch("bnbagent.erc8183.client.RouterClient") as mrc, + patch("bnbagent.erc8183.client.PolicyClient") as mpc, + ): commerce = MagicMock() commerce.address = FAKE_COMMERCE router = MagicMock() @@ -65,10 +67,12 @@ class TestInit: def test_allows_read_only_without_wallet(self, mock_web3): """wallet_provider=None builds a read-only client (writes raise later via _send_tx); address is None.""" - with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \ - patch("bnbagent.erc8183.client.CommerceClient"), \ - patch("bnbagent.erc8183.client.RouterClient"), \ - patch("bnbagent.erc8183.client.PolicyClient"): + with ( + patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), + patch("bnbagent.erc8183.client.CommerceClient"), + patch("bnbagent.erc8183.client.RouterClient"), + patch("bnbagent.erc8183.client.PolicyClient"), + ): client = ERC8183Client(None, network=_fake_network()) assert client.address is None assert client._wallet_provider is None @@ -99,13 +103,13 @@ def test_chain_id_mismatch_raises(self, mock_web3): def test_accepts_network_string(self, mock_web3): """String preset is resolved via ``resolve_network`` under the hood.""" fake_net = _fake_network() - with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \ - patch( - "bnbagent.erc8183.client.resolve_network", return_value=fake_net - ) as resolve, \ - patch("bnbagent.erc8183.client.CommerceClient") as mcc, \ - patch("bnbagent.erc8183.client.RouterClient") as mrc, \ - patch("bnbagent.erc8183.client.PolicyClient") as mpc: + with ( + patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), + patch("bnbagent.erc8183.client.resolve_network", return_value=fake_net) as resolve, + patch("bnbagent.erc8183.client.CommerceClient") as mcc, + patch("bnbagent.erc8183.client.RouterClient") as mrc, + patch("bnbagent.erc8183.client.PolicyClient") as mpc, + ): mcc.return_value.address = FAKE_COMMERCE mrc.return_value.address = FAKE_ROUTER mpc.return_value.address = FAKE_POLICY @@ -133,7 +137,9 @@ def test_defaults_to_router_as_evaluator_and_hook(self, facade): def test_allows_overriding_hook(self, facade): facade.commerce.create_job.return_value = {"jobId": 1} custom_hook = "0x" + "11" * 20 - facade.create_job(expired_at=123, description="d", hook=custom_hook, skip_expiry_check=True) + facade.create_job( + expired_at=123, description="d", hook=custom_hook, skip_expiry_check=True + ) _, kwargs = facade.commerce.create_job.call_args assert kwargs["evaluator"] == FAKE_ROUTER assert kwargs["hook"] == custom_hook @@ -147,6 +153,7 @@ def test_rejects_expired_at_within_dispute_window(self, facade): Regression for https://github.com/bnb-chain/bnbagent-sdk/issues/41. """ import time + facade.policy.dispute_window.return_value = 7 * 86400 too_close = int(time.time()) + 86400 # 24h, well inside 7d window with pytest.raises(ValueError, match="dispute_window"): @@ -155,6 +162,7 @@ def test_rejects_expired_at_within_dispute_window(self, facade): def test_accepts_expired_at_beyond_dispute_window(self, facade): import time + facade.policy.dispute_window.return_value = 7 * 86400 far_enough = int(time.time()) + 8 * 86400 + 60 # 8d + 1min facade.commerce.create_job.return_value = {"jobId": 99} @@ -163,6 +171,7 @@ def test_accepts_expired_at_beyond_dispute_window(self, facade): def test_skip_expiry_check_bypasses_validation(self, facade): import time + facade.policy.dispute_window.return_value = 7 * 86400 facade.commerce.create_job.return_value = {"jobId": 99} facade.create_job( @@ -209,9 +218,7 @@ def test_skips_approve_when_allowance_sufficient(self, facade): def test_approves_default_floor_when_amount_below_floor(self, facade): erc20 = self._prime(facade, current_allowance=0, decimals=6) facade.fund(job_id=1, amount=1 * 10**6) - erc20.approve.assert_called_once_with( - FAKE_COMMERCE, DEFAULT_APPROVE_FLOOR_UNITS * 10**6 - ) + erc20.approve.assert_called_once_with(FAKE_COMMERCE, DEFAULT_APPROVE_FLOOR_UNITS * 10**6) def test_approves_exact_amount_when_above_default_floor(self, facade): erc20 = self._prime(facade, current_allowance=0, decimals=6) @@ -331,16 +338,16 @@ def test_rate_limit_raises_typed_error(self): from bnbagent.exceptions import RpcRangeLimitError policy = self._policy() - policy.contract.events.JobInitialised.return_value.get_logs.side_effect = ( - Exception("{'code': -32005, 'message': 'limit exceeded'}") + policy.contract.events.JobInitialised.return_value.get_logs.side_effect = Exception( + "{'code': -32005, 'message': 'limit exceeded'}" ) with pytest.raises(RpcRangeLimitError): policy.get_deliverable_url(1) def test_other_query_error_still_returns_none(self): policy = self._policy() - policy.contract.events.JobInitialised.return_value.get_logs.side_effect = ( - Exception("some other rpc problem") + policy.contract.events.JobInitialised.return_value.get_logs.side_effect = Exception( + "some other rpc problem" ) assert policy.get_deliverable_url(1) is None diff --git a/python/tests/test_erc8183_job_ops.py b/python/tests/test_erc8183_job_ops.py index 4dc8991..9a0e0b0 100644 --- a/python/tests/test_erc8183_job_ops.py +++ b/python/tests/test_erc8183_job_ops.py @@ -340,9 +340,7 @@ class TestErrorSanitization: async def test_get_job_does_not_leak_rpc_url(self): ops = _make_ops() client = _inject_client(ops) - client.get_job.side_effect = Exception( - f"429 Too Many Requests for url: {self.SECRET}" - ) + client.get_job.side_effect = Exception(f"429 Too Many Requests for url: {self.SECRET}") result = await ops.get_job(1) assert result["success"] is False assert "SECRET_KEY" not in result["error"] @@ -352,9 +350,7 @@ async def test_get_job_does_not_leak_rpc_url(self): async def test_verify_job_does_not_leak_rpc_url(self): ops = _make_ops() client = _inject_client(ops) - client.get_job.side_effect = Exception( - f"Max retries exceeded with url: {self.SECRET}" - ) + client.get_job.side_effect = Exception(f"Max retries exceeded with url: {self.SECRET}") result = await ops.verify_job(1) assert result["valid"] is False assert "SECRET_KEY" not in result["error"] @@ -381,9 +377,7 @@ async def test_startup_scan_filters_to_funded_owned(self): mine_funded = replace(_job(status=JobStatus.FUNDED, provider=ME), id=1) other_funded = replace(_job(status=JobStatus.FUNDED, provider=OTHER), id=2) mine_completed = replace(_job(status=JobStatus.COMPLETED, provider=ME), id=3) - client.commerce.get_jobs_batch.return_value = [ - mine_funded, other_funded, mine_completed - ] + client.commerce.get_jobs_batch.return_value = [mine_funded, other_funded, mine_completed] result = await ops.get_pending_jobs() assert result["success"] @@ -457,7 +451,9 @@ async def test_returns_only_submitted_for_provider(self): other_submitted = replace(_job(status=JobStatus.SUBMITTED, provider=OTHER), id=2) mine_funded = replace(_job(status=JobStatus.FUNDED, provider=ME), id=3) client.commerce.get_jobs_batch.return_value = [ - mine_submitted, other_submitted, mine_funded + mine_submitted, + other_submitted, + mine_funded, ] result = await ops.get_submitted_jobs() @@ -490,11 +486,11 @@ def test_decodes_submitted_at_from_index_9(self): "0x" + "33" * 20, "desc", 1000, - 2000, # expiredAt (index 6) + 2000, # expiredAt (index 6) JobStatus.SUBMITTED.value, # status (index 7) - "0x" + "44" * 20, # hook (index 8) - 1500, # submittedAt (index 9) - b"\x00" * 32, # deliverable (index 10) + "0x" + "44" * 20, # hook (index 8) + 1500, # submittedAt (index 9) + b"\x00" * 32, # deliverable (index 10) ) job = _decode_job(raw) assert job.submitted_at == 1500 @@ -648,8 +644,7 @@ def test_non_transient_url_is_redacted_not_replaced(self): from bnbagent.erc8183.job_ops import _exc_error_fields exc = RuntimeError( - "Cannot publish: ERC8183_AGENT_URL is not set " - "(e.g. http://localhost:8003/erc8183)" + "Cannot publish: ERC8183_AGENT_URL is not set (e.g. http://localhost:8003/erc8183)" ) fields = _exc_error_fields(exc) assert "ERC8183_AGENT_URL" in fields["error"] @@ -703,7 +698,12 @@ async def test_never_submitted_job_is_genuine_404(self): @pytest.mark.asyncio async def test_unknown_status_is_chain_unavailable(self): ops = self._ops( - {"success": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True} + { + "success": False, + "error": "Temporary chain/RPC error", + "error_code": "chain_unavailable", + "retryable": True, + } ) result = await ops.get_response(1) assert result["error_code"] == "chain_unavailable" diff --git a/python/tests/test_erc8183_policy.py b/python/tests/test_erc8183_policy.py index 674f234..d02ae97 100644 --- a/python/tests/test_erc8183_policy.py +++ b/python/tests/test_erc8183_policy.py @@ -12,7 +12,9 @@ def policy_client(mock_web3): wallet = MagicMock() wallet.address = "0x" + "aa" * 20 # Mocking the _load_abi to avoid file I/O errors if ABI isn't found - with patch("bnbagent.erc8183.policy._load_abi", return_value=[{"type": "function", "name": "dispute"}]): + with patch( + "bnbagent.erc8183.policy._load_abi", return_value=[{"type": "function", "name": "dispute"}] + ): client = PolicyClient(mock_web3, "0x" + "11" * 20, wallet) client._send_tx = MagicMock() client._execute_intent = MagicMock() @@ -102,7 +104,9 @@ def test_set_quorum(self, policy_client): class TestGetDeliverableUrl: def test_rpc_range_error(self, policy_client): policy_client.contract.events.JobInitialised = MagicMock() - policy_client.contract.events.JobInitialised().get_logs.side_effect = Exception("limit exceeded") + policy_client.contract.events.JobInitialised().get_logs.side_effect = Exception( + "limit exceeded" + ) with pytest.raises(RpcRangeLimitError): policy_client.get_deliverable_url(1, hint_block=100) diff --git a/python/tests/test_erc8183_router.py b/python/tests/test_erc8183_router.py index 2464d76..d5bf467 100644 --- a/python/tests/test_erc8183_router.py +++ b/python/tests/test_erc8183_router.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch import pytest -from web3 import Web3 from bnbagent.erc8183.router import RouterClient from bnbagent.erc8183.types import JobStatus, Verdict @@ -11,7 +10,9 @@ def router_client(mock_web3): wallet = MagicMock() wallet.address = "0x" + "aa" * 20 - with patch("bnbagent.erc8183.router._load_abi", return_value=[{"type": "function", "name": "settle"}]): + with patch( + "bnbagent.erc8183.router._load_abi", return_value=[{"type": "function", "name": "settle"}] + ): client = RouterClient(mock_web3, "0x" + "11" * 20, wallet) client._execute_intent = MagicMock() client._call_with_retry = MagicMock() @@ -54,7 +55,11 @@ def test_paused(self, router_client): def test_get_job_registered_events(self, router_client): router_client.contract.events.JobRegistered = MagicMock() router_client.contract.events.JobRegistered().get_logs.return_value = [ - {"args": {"jobId": 1, "policy": "0xcc", "client": "0xaa"}, "blockNumber": 100, "transactionHash": b"hash"} + { + "args": {"jobId": 1, "policy": "0xcc", "client": "0xaa"}, + "blockNumber": 100, + "transactionHash": b"hash", + } ] logs = router_client.get_job_registered_events(0, client="0x" + "aa" * 20) assert len(logs) == 1 @@ -63,7 +68,11 @@ def test_get_job_registered_events(self, router_client): def test_get_job_settled_events(self, router_client): router_client.contract.events.JobSettled = MagicMock() router_client.contract.events.JobSettled().get_logs.return_value = [ - {"args": {"jobId": 1, "verdict": 1, "reason": b"reason"}, "blockNumber": 100, "transactionHash": b"hash"} + { + "args": {"jobId": 1, "verdict": 1, "reason": b"reason"}, + "blockNumber": 100, + "transactionHash": b"hash", + } ] logs = router_client.get_job_settled_events(0, verdict=Verdict.APPROVE) assert len(logs) == 1 diff --git a/python/tests/test_erc8183_schema.py b/python/tests/test_erc8183_schema.py index 7d0beb5..76b71b1 100644 --- a/python/tests/test_erc8183_schema.py +++ b/python/tests/test_erc8183_schema.py @@ -71,7 +71,10 @@ def test_manifest_hash_differs_for_different_job_id(self): d1 = _manifest_dict() d2 = _manifest_dict() d2["job_id"] = 999 - assert DeliverableManifest.from_dict(d1).manifest_hash() != DeliverableManifest.from_dict(d2).manifest_hash() + assert ( + DeliverableManifest.from_dict(d1).manifest_hash() + != DeliverableManifest.from_dict(d2).manifest_hash() + ) def test_verify_returns_true_for_matching_hash(self): m = DeliverableManifest.from_dict(_manifest_dict()) diff --git a/python/tests/test_ipfs_storage.py b/python/tests/test_ipfs_storage.py index 151a3ab..9f43b1b 100644 --- a/python/tests/test_ipfs_storage.py +++ b/python/tests/test_ipfs_storage.py @@ -34,7 +34,9 @@ async def test_upload_posts_to_pinata(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): url = await provider.upload({"test": "data"}) assert url == f"ipfs://{VALID_CID}" @@ -54,7 +56,9 @@ async def test_upload_returns_ipfs_url(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): url = await provider.upload({"data": 1}) assert url.startswith("ipfs://") @@ -71,7 +75,9 @@ async def test_upload_with_filename(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): _url = await provider.upload({"data": 1}, filename="job-5.json") call_kwargs = mock_client.post.call_args @@ -90,7 +96,9 @@ async def test_upload_missing_cid_raises(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): with pytest.raises(StorageError, match="Unexpected pinning response"): await provider.upload({"data": 1}) @@ -106,7 +114,9 @@ async def test_download_from_gateway(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): result = await provider.download(f"ipfs://{VALID_CID}") assert result["downloaded"] is True @@ -125,7 +135,9 @@ async def test_exists_true(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): assert await provider.exists(f"ipfs://{VALID_CID}") is True @pytest.mark.asyncio @@ -139,7 +151,9 @@ async def test_exists_false(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): assert await provider.exists(f"ipfs://{VALID_CID_2}") is False def test_get_gateway_url(self): @@ -160,7 +174,9 @@ async def test_upload_uses_cid_key(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client): + with patch( + "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client + ): url = await provider.upload({"test": 1}) assert url == f"ipfs://{VALID_CID_3}" diff --git a/python/tests/test_local_storage.py b/python/tests/test_local_storage.py index 05cf4eb..c104e5e 100644 --- a/python/tests/test_local_storage.py +++ b/python/tests/test_local_storage.py @@ -48,7 +48,7 @@ async def test_upload_without_filename_uses_hash(self, tmp_path): @pytest.mark.asyncio async def test_file_permissions(self, tmp_path): provider = LocalStorageProvider(str(tmp_path / "data")) - url = await provider.upload({"key": "val"}, "test.json") + await provider.upload({"key": "val"}, "test.json") filepath = tmp_path / "data" / "test.json" mode = os.stat(filepath).st_mode assert mode & stat.S_IRUSR @@ -56,6 +56,7 @@ async def test_file_permissions(self, tmp_path): def test_upload_sync(self, tmp_path): from bnbagent.storage import upload_sync + provider = LocalStorageProvider(str(tmp_path / "data")) url = upload_sync(provider, {"sync": True}, "sync-test.json") assert url.startswith("file://") @@ -136,6 +137,7 @@ async def test_upload_path_traversal_via_symlink(self, tmp_path): def test_upload_sync_path_traversal_blocked(self, tmp_path): from bnbagent.storage import upload_sync + provider = LocalStorageProvider(str(tmp_path / "data")) with pytest.raises(StorageError, match="Path traversal blocked"): upload_sync(provider, {"k": "v"}, "../escape.json") diff --git a/python/tests/test_multicall.py b/python/tests/test_multicall.py index 7c97998..89b6825 100644 --- a/python/tests/test_multicall.py +++ b/python/tests/test_multicall.py @@ -1,6 +1,6 @@ """Tests for Multicall3 batch read utility.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from eth_abi import encode as abi_encode @@ -206,7 +206,7 @@ def test_decode_roundtrip(self): decoded = abi_decode(output_types, fake_return) # decoded is ((42, addr, addr, addr, "test job", 100, ...),) — unwrap tuple job = decoded[0] - assert job[0] == 42 # id + assert job[0] == 42 # id assert job[4] == "test job" # description - assert job[5] == 100 # budget - assert job[7] == 1 # status + assert job[5] == 100 # budget + assert job[7] == 1 # status diff --git a/python/tests/test_negotiation.py b/python/tests/test_negotiation.py index dfc41e7..89095d8 100644 --- a/python/tests/test_negotiation.py +++ b/python/tests/test_negotiation.py @@ -329,7 +329,9 @@ def test_compact_json(self): def test_sanitizes_brackets_in_task(self): result = _make_accepted_result(task="[REQUEST] tricky task [VERIFY]") desc = build_job_description(result) - assert "[" not in desc or "negotiation_hash" in desc # only hex values may have no brackets + assert ( + "[" not in desc or "negotiation_hash" in desc + ) # only hex values may have no brackets parsed = json.loads(desc) assert "[" not in parsed["task"] @@ -530,6 +532,7 @@ def test_no_sig_without_wallet(self): def test_negotiation_hash_is_keccak256_of_content(self): from web3 import Web3 + from bnbagent.erc8183.negotiation import _build_description_content mock_wallet = MagicMock() @@ -552,7 +555,9 @@ def test_negotiation_hash_is_keccak256_of_content(self): canonical = json.dumps(content, sort_keys=True, separators=(",", ":")) expected_hash = "0x" + Web3.keccak(text=canonical).hex().lstrip("0x") # Compare (both should have 0x prefix and 64 hex chars) - assert result.negotiation_hash == expected_hash or result.negotiation_hash.lstrip("0x") == expected_hash.lstrip("0x") + assert result.negotiation_hash == expected_hash or result.negotiation_hash.lstrip( + "0x" + ) == expected_hash.lstrip("0x") def test_invalid_format_rejection(self): handler = self._make_handler() @@ -564,10 +569,12 @@ def test_rejects_when_description_too_long(self): """A task that would overflow the on-chain description cap is rejected at negotiation time with TASK_TOO_LONG, before any quote is signed.""" handler = self._make_handler() - result = handler.negotiate({ - "task_description": "x" * 10_000, - "terms": {"deliverables": "summary", "quality_standards": "accurate"}, - }) + result = handler.negotiate( + { + "task_description": "x" * 10_000, + "terms": {"deliverables": "summary", "quality_standards": "accurate"}, + } + ) assert result.accepted is False assert result.response.get("reason_code") == ReasonCode.TASK_TOO_LONG @@ -635,12 +642,14 @@ def test_content_includes_chain_id_when_set(self): assert content["chain_id"] == 56 # And the negotiation_hash must be derived from the chain-bound content. from web3 import Web3 + canonical = json.dumps(content, sort_keys=True, separators=(",", ":")) expected = "0x" + Web3.keccak(text=canonical).hex().lstrip("0x") assert result.negotiation_hash.lstrip("0x") == expected.lstrip("0x") def test_content_includes_verifying_contract_when_set(self): from web3 import Web3 + from bnbagent.erc8183.negotiation import _build_description_content commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de") @@ -654,7 +663,9 @@ def test_content_includes_verifying_contract_when_set(self): result = handler.negotiate(self._request()) content = _build_description_content( - result.to_dict(), chain_id=97, verifying_contract=commerce_addr, + result.to_dict(), + chain_id=97, + verifying_contract=commerce_addr, ) assert content["verifying_contract"] == commerce_addr # checksummed # Hash binds the contract too. @@ -679,12 +690,22 @@ def test_different_chain_id_produces_different_signature(self): mock_wallet = MagicMock() mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65} - h_testnet = self._make_handler( - wallet_provider=mock_wallet, chain_id=97, - ).negotiate(self._request()).negotiation_hash - h_mainnet = self._make_handler( - wallet_provider=mock_wallet, chain_id=56, - ).negotiate(self._request()).negotiation_hash + h_testnet = ( + self._make_handler( + wallet_provider=mock_wallet, + chain_id=97, + ) + .negotiate(self._request()) + .negotiation_hash + ) + h_mainnet = ( + self._make_handler( + wallet_provider=mock_wallet, + chain_id=56, + ) + .negotiate(self._request()) + .negotiation_hash + ) assert h_testnet != h_mainnet @@ -721,18 +742,21 @@ def _make_handler(self, **kwargs): def test_build_job_description_includes_chain_id_when_present(self): from web3 import Web3 - commerce_addr = Web3.to_checksum_address( - "0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de" - ) + + commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de") mock_wallet = MagicMock() mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65} handler = self._make_handler( - wallet_provider=mock_wallet, chain_id=56, verifying_contract=commerce_addr, + wallet_provider=mock_wallet, + chain_id=56, + verifying_contract=commerce_addr, + ) + result = handler.negotiate( + { + "task_description": "Get news", + "terms": {"deliverables": "summary", "quality_standards": "accurate"}, + } ) - result = handler.negotiate({ - "task_description": "Get news", - "terms": {"deliverables": "summary", "quality_standards": "accurate"}, - }) description_json = build_job_description(result.to_dict()) parsed = json.loads(description_json) @@ -744,18 +768,21 @@ def test_signature_roundtrip_with_chain_binding(self): compute by stripping negotiation_hash/provider_sig from the on-chain JSON and re-running keccak. Without this, provider_sig is useless.""" from web3 import Web3 - commerce_addr = Web3.to_checksum_address( - "0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de" - ) + + commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de") mock_wallet = MagicMock() mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65} handler = self._make_handler( - wallet_provider=mock_wallet, chain_id=97, verifying_contract=commerce_addr, + wallet_provider=mock_wallet, + chain_id=97, + verifying_contract=commerce_addr, + ) + result = handler.negotiate( + { + "task_description": "Get news", + "terms": {"deliverables": "summary", "quality_standards": "accurate"}, + } ) - result = handler.negotiate({ - "task_description": "Get news", - "terms": {"deliverables": "summary", "quality_standards": "accurate"}, - }) # Simulate downstream verifier: description_json = build_job_description(result.to_dict()) @@ -789,10 +816,12 @@ def test_signing_failure_is_logged(self, caplog): handler = self._make_handler(wallet_provider=mock_wallet, chain_id=97) with caplog.at_level("WARNING"): - result = handler.negotiate({ - "task_description": "Get news", - "terms": {"deliverables": "summary", "quality_standards": "accurate"}, - }) + result = handler.negotiate( + { + "task_description": "Get news", + "terms": {"deliverables": "summary", "quality_standards": "accurate"}, + } + ) # Quote still returned but without sig. assert result.accepted is True diff --git a/python/tests/test_networks_addresses.py b/python/tests/test_networks_addresses.py index 6e74496..494fada 100644 --- a/python/tests/test_networks_addresses.py +++ b/python/tests/test_networks_addresses.py @@ -9,7 +9,6 @@ BNB_CHAIN_ADDRESSES, BSC_MAINNET_CHAIN_ID, BSC_TESTNET_CHAIN_ID, - DeployedAddresses, PAYMENT_TOKEN_EIP712_NAME, PAYMENT_TOKEN_EIP712_VERSION, get_address, @@ -36,9 +35,13 @@ def test_all_addresses_are_checksummed(): """Every address on every chain must be EIP-55 checksum-encoded.""" for chain_id, deploy in BNB_CHAIN_ADDRESSES.items(): for field_name in ( - "payment_token", "treasury", - "commerce_proxy", "commerce_impl", - "router_proxy", "router_impl", "policy", + "payment_token", + "treasury", + "commerce_proxy", + "commerce_impl", + "router_proxy", + "router_impl", + "policy", ): addr = getattr(deploy, field_name) assert Web3.is_checksum_address(addr), ( diff --git a/python/tests/test_signing_policy.py b/python/tests/test_signing_policy.py index 235d45b..541f4e4 100644 --- a/python/tests/test_signing_policy.py +++ b/python/tests/test_signing_policy.py @@ -223,9 +223,7 @@ def test_rejects_missing_validity_fields_when_required(): def _twa_fields_with(name, key, new_value): - return [ - {**f, key: new_value} if f["name"] == name else f for f in TWA_FIELDS - ] + return [{**f, key: new_value} if f["name"] == name else f for f in TWA_FIELDS] def test_rejects_value_declared_as_int256(): @@ -296,8 +294,10 @@ def test_canonical_shape_still_accepted(): def test_receive_with_authorization_shares_the_canonical_shape(): p = SigningPolicy.strict_default() domain = { - "name": "United Stables", "version": "1", - "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET, + "name": "United Stables", + "version": "1", + "chainId": BSC_MAINNET_CHAIN_ID, + "verifyingContract": U_MAINNET, } types = { "EIP712Domain": EIP712DOMAIN_FIELDS, @@ -312,8 +312,10 @@ def test_unknown_primary_type_is_not_shape_pinned(): """ p = SigningPolicy.permissive(allow_in_production=True) domain = { - "name": "Custom", "version": "1", - "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET, + "name": "Custom", + "version": "1", + "chainId": BSC_MAINNET_CHAIN_ID, + "verifyingContract": U_MAINNET, } types = { "EIP712Domain": EIP712DOMAIN_FIELDS, @@ -350,8 +352,10 @@ def test_eip712domain_shape_is_not_pinned(): {"name": "salt", "type": "bytes32"}, ] domain = { - "name": "United Stables", "version": "1", - "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET, + "name": "United Stables", + "version": "1", + "chainId": BSC_MAINNET_CHAIN_ID, + "verifyingContract": U_MAINNET, } types = { "EIP712Domain": odd_domain_fields, @@ -430,7 +434,10 @@ def test_extend_overrides_scalars(): def test_permissive_passes_unknown_domain_and_unknown_type(): p = SigningPolicy.permissive() domain = {"chainId": 999, "verifyingContract": "0x" + "f" * 40} - types = {"EIP712Domain": EIP712DOMAIN_FIELDS, "SomethingExotic": [{"name": "x", "type": "uint256"}]} + types = { + "EIP712Domain": EIP712DOMAIN_FIELDS, + "SomethingExotic": [{"name": "x", "type": "uint256"}], + } msg = {"x": 1} # Must not raise pt = check(p, domain, types, msg, now=NOW) @@ -463,9 +470,10 @@ def test_policy_violation_carries_structured_diagnostics(): def test_infer_primary_type_returns_non_domain(): - assert infer_primary_type( - {"EIP712Domain": [], "TransferWithAuthorization": []} - ) == "TransferWithAuthorization" + assert ( + infer_primary_type({"EIP712Domain": [], "TransferWithAuthorization": []}) + == "TransferWithAuthorization" + ) def test_infer_primary_type_rejects_empty(): @@ -482,15 +490,11 @@ def test_infer_primary_type_rejects_multiple(): def test_eip3009_types_set_contents(): - assert EIP3009_TYPES == frozenset( - {"TransferWithAuthorization", "ReceiveWithAuthorization"} - ) + assert EIP3009_TYPES == frozenset({"TransferWithAuthorization", "ReceiveWithAuthorization"}) def test_permit_unbounded_types_contents(): - assert PERMIT_UNBOUNDED_TYPES == frozenset( - {"Permit", "PermitSingle", "PermitBatch"} - ) + assert PERMIT_UNBOUNDED_TYPES == frozenset({"Permit", "PermitSingle", "PermitBatch"}) # ── Serialization ──────────────────────────────────────────────────────── @@ -562,8 +566,9 @@ def test_str_handles_empty_policy_cleanly(): # ── permissive() env guard ────────────────────────────────────────────── -@pytest.mark.parametrize("env_value", ["prod", "production", "live", "mainnet-prod", - "PROD", " Production ", "LIVE"]) +@pytest.mark.parametrize( + "env_value", ["prod", "production", "live", "mainnet-prod", "PROD", " Production ", "LIVE"] +) def test_permissive_refuses_in_production(monkeypatch, env_value): monkeypatch.setenv("ENV", env_value) with pytest.raises(RuntimeError, match="indicates production"): diff --git a/python/tests/test_storage_from_env.py b/python/tests/test_storage_from_env.py index aca74fc..0f767bf 100644 --- a/python/tests/test_storage_from_env.py +++ b/python/tests/test_storage_from_env.py @@ -2,8 +2,8 @@ import pytest -from bnbagent.storage.local_storage_provider import LocalStorageProvider from bnbagent.storage.ipfs_storage_provider import IPFSStorageProvider +from bnbagent.storage.local_storage_provider import LocalStorageProvider class TestLocalProviderFromEnv: diff --git a/python/tests/test_turnkey_client.py b/python/tests/test_turnkey_client.py new file mode 100644 index 0000000..12b845f --- /dev/null +++ b/python/tests/test_turnkey_client.py @@ -0,0 +1,158 @@ +"""Unit tests for the stamped Turnkey HTTP activity client.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from bnbagent.wallets.turnkey.client import TurnkeyApiError, TurnkeyClient + + +def _activity(status: str, **extra) -> dict: + return {"id": "activity-1", "status": status, **extra} + + +def _client() -> tuple[TurnkeyClient, MagicMock, MagicMock]: + session = MagicMock(spec=requests.Session) + stamper = MagicMock() + with patch("bnbagent.wallets.turnkey.client.ApiKeyStamper", return_value=stamper): + client = TurnkeyClient( + api_base_url="https://api.turnkey.example/", + api_public_key="public", + api_private_key="private", + organization_id="org-1", + session=session, + timeout=2.0, + ) + return client, session, stamper + + +class TestSubmit: + def test_returns_immediately_when_submission_is_completed(self): + client, _, _ = _client() + completed = _activity("ACTIVITY_STATUS_COMPLETED", result={"ok": True}) + client._post = MagicMock(return_value=completed) + + with patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep: + assert client._submit("/submit", {}) is completed + + client._post.assert_called_once_with("/submit", {}) + sleep.assert_not_called() + + def test_checks_completion_returned_by_last_poll(self): + client, _, _ = _client() + created = _activity("ACTIVITY_STATUS_CREATED") + completed = _activity("ACTIVITY_STATUS_COMPLETED", result={"ok": True}) + client._post = MagicMock(side_effect=[created, *([created] * 9), completed]) + + with patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep: + assert client._submit("/submit", {}) is completed + + assert client._post.call_count == 11 + assert sleep.call_count == 10 + + def test_checks_failure_returned_by_last_poll(self): + client, _, _ = _client() + created = _activity("ACTIVITY_STATUS_CREATED") + failed = _activity("ACTIVITY_STATUS_FAILED", failure="policy rejected") + client._post = MagicMock(side_effect=[created, *([created] * 9), failed]) + + with ( + patch("bnbagent.wallets.turnkey.client.time.sleep"), + pytest.raises(TurnkeyApiError, match="ended in ACTIVITY_STATUS_FAILED") as excinfo, + ): + client._submit("/submit", {}) + + assert excinfo.value.activity_status == "ACTIVITY_STATUS_FAILED" + assert client._post.call_count == 11 + + def test_raises_after_poll_timeout(self): + client, _, _ = _client() + created = _activity("ACTIVITY_STATUS_CREATED") + client._post = MagicMock(return_value=created) + + with ( + patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep, + pytest.raises(TurnkeyApiError, match="still pending after 5s") as excinfo, + ): + client._submit("/submit", {}) + + assert excinfo.value.activity_status == "ACTIVITY_STATUS_CREATED" + assert client._post.call_count == 11 + assert sleep.call_count == 10 + + +class TestPost: + def test_stamp_matches_exact_wire_body(self): + client, session, stamper = _client() + body = {"organizationId": "org-1", "parameters": {"probe": True}} + payload = json.dumps(body, separators=(",", ":")) + stamper.stamp.return_value = ("X-Stamp", "stamp-value") + response = session.post.return_value + response.status_code = 200 + response.json.return_value = {"activity": _activity("ACTIVITY_STATUS_COMPLETED")} + + client._post("/public/v1/submit/probe", body) + + stamper.stamp.assert_called_once_with(payload) + session.post.assert_called_once_with( + "https://api.turnkey.example/public/v1/submit/probe", + data=payload.encode("utf-8"), + headers={"Content-Type": "application/json", "X-Stamp": "stamp-value"}, + timeout=2.0, + ) + + @pytest.mark.parametrize( + ("response_body", "expected"), + [ + ({"message": "invalid stamp"}, "invalid stamp"), + (["first error", "second error"], "first error"), + ], + ) + def test_maps_http_error_with_json_body(self, response_body, expected): + client, session, stamper = _client() + stamper.stamp.return_value = ("X-Stamp", "stamp-value") + response = session.post.return_value + response.status_code = 400 + response.json.return_value = response_body + + with pytest.raises(TurnkeyApiError, match=expected) as excinfo: + client._post("/public/v1/submit/probe", {}) + + assert excinfo.value.status_code == 400 + + def test_maps_http_error_with_text_body(self): + client, session, stamper = _client() + stamper.stamp.return_value = ("X-Stamp", "stamp-value") + response = session.post.return_value + response.status_code = 500 + response.json.side_effect = ValueError + response.text = "upstream unavailable" + + with pytest.raises(TurnkeyApiError, match="upstream unavailable") as excinfo: + client._post("/public/v1/submit/probe", {}) + + assert excinfo.value.status_code == 500 + + def test_rejects_non_json_success_body(self): + client, session, stamper = _client() + stamper.stamp.return_value = ("X-Stamp", "stamp-value") + response = session.post.return_value + response.status_code = 200 + response.json.side_effect = ValueError + + with pytest.raises(TurnkeyApiError, match="returned non-JSON body"): + client._post("/public/v1/submit/probe", {}) + + def test_rejects_success_body_without_activity_envelope(self): + client, session, stamper = _client() + stamper.stamp.return_value = ("X-Stamp", "stamp-value") + response = session.post.return_value + response.status_code = 200 + response.json.return_value = {"notActivity": {}} + + with pytest.raises(TurnkeyApiError, match="has no activity envelope"): + client._post("/public/v1/submit/probe", {}) diff --git a/python/tests/test_turnkey_provider.py b/python/tests/test_turnkey_provider.py index d7ed176..4163178 100644 --- a/python/tests/test_turnkey_provider.py +++ b/python/tests/test_turnkey_provider.py @@ -387,11 +387,24 @@ def test_quota_exhaustion_hint(self, fake_client): with pytest.raises(RuntimeError, match="25 billed signatures/month"): provider.sign_message("x") - def test_rate_limit_hint(self, fake_client): + @pytest.mark.parametrize( + "message", + [ + "Turnkey error 8: RATE_LIMIT_EXCEEDED", + "Turnkey error 8: RATE LIMIT EXCEEDED", + "Turnkey error 8: rate-limit exceeded", + "Turnkey error 8: ratelimit hit", + ], + ) + def test_rate_limit_hint(self, fake_client, message): provider = make_provider(fake_client) - fake_client.failure = TurnkeyApiError( - "Turnkey error 8: RATE_LIMIT_EXCEEDED", status_code=429 - ) + fake_client.failure = TurnkeyApiError(message) + with pytest.raises(RuntimeError, match="1 request/second"): + provider.sign_message("x") + + def test_rate_limit_status_hint(self, fake_client): + provider = make_provider(fake_client) + fake_client.failure = TurnkeyApiError("too many requests", status_code=429) with pytest.raises(RuntimeError, match="1 request/second"): provider.sign_message("x") diff --git a/python/tests/test_twak_provider.py b/python/tests/test_twak_provider.py index aee0910..f6b27d8 100644 --- a/python/tests/test_twak_provider.py +++ b/python/tests/test_twak_provider.py @@ -51,7 +51,11 @@ # Canonical twak --json outputs (field-verified v0.18.0 shapes). _REGISTER_OUT = { - "success": True, "agentId": 42, "hash": "0xreg", "owner": FAKE_ADDRESS, "chain": "bsc", + "success": True, + "agentId": 42, + "hash": "0xreg", + "owner": FAKE_ADDRESS, + "chain": "bsc", } # spec spelling ("txHash"): exercises the hash-alias chain on the happy path _SETMETA_OUT = {"success": True, "txHash": "0xmeta", "chain": "bsc"} @@ -130,6 +134,7 @@ def _make_twak_contract(twak): # ── TWAKProvider is a self-broadcasting executor ── + def test_twak_provider_is_intent_executor(): assert isinstance(TWAKProvider(), IntentExecutor) @@ -184,17 +189,20 @@ def test_make_executor_captures_paymaster_url_and_writes_carry_flag(): # write commands as --paymaster-url; reads (the status probe) never are. run, calls = _intent_router(_TX_OUT) twak = TWAKProvider() - executor = twak.make_executor( - ExecutionContext(web3=MagicMock(), paymaster=_paymaster()) - ) + executor = twak.make_executor(ExecutionContext(web3=MagicMock(), paymaster=_paymaster())) assert executor is twak - _execute( - twak, Intent(name=ERC8183_SETTLE, kwargs={"job_id": 137, "evidence": b""}), run - ) + _execute(twak, Intent(name=ERC8183_SETTLE, kwargs={"job_id": 137, "evidence": b""}), run) assert calls[0] == _WALLET_STATUS_CMD assert calls[1] == [ - "twak", "erc8183", "settle", "137", - "--paymaster-url", _PAYMASTER_URL, "--chain", "bsc", "--json", + "twak", + "erc8183", + "settle", + "137", + "--paymaster-url", + _PAYMASTER_URL, + "--chain", + "bsc", + "--json", ] @@ -265,6 +273,7 @@ def test_contract_paymaster_flows_to_twak_writes(): # ── erc8004.register: atomic --metadata flags (v0.18.0, no replay) ── + def test_register_atomic_metadata_flags_and_field_mapping(): run, calls = _router() twak = TWAKProvider() @@ -294,11 +303,18 @@ def test_register_atomic_metadata_flags_and_field_mapping(): # one atomic register invocation: repeatable --metadata, no set-metadata replay assert calls[0] == _WALLET_STATUS_CMD assert calls[1] == [ - "twak", "erc8004", "register", - "--uri", "https://agent.example/card.json", - "--metadata", "built_with=https://github.com/bnb-chain/bnbagent-sdk#v1", - "--metadata", "foo=bar", - "--chain", "bsc", "--json", + "twak", + "erc8004", + "register", + "--uri", + "https://agent.example/card.json", + "--metadata", + "built_with=https://github.com/bnb-chain/bnbagent-sdk#v1", + "--metadata", + "foo=bar", + "--chain", + "bsc", + "--json", ] assert len(calls) == 2 @@ -331,6 +347,7 @@ def test_contract_register_end_to_end_no_web3_send(): # ── other erc8004 intents ── + def test_set_metadata_via_contract(): run, calls = _router() twak = TWAKProvider() @@ -355,6 +372,7 @@ def test_set_agent_uri_via_contract_uses_set_uri(): # ── erc8183 dispatch table: exact argv + normalised result ── + @pytest.mark.parametrize( ("name", "kwargs", "expected_argv"), [ @@ -368,8 +386,12 @@ def test_set_agent_uri_via_contract_uses_set_uri(): ERC8183_SET_PROVIDER, {"job_id": 137, "provider": _PROVIDER_ADDR, "opt_params": b"\x01\x02"}, [ - "set-provider", "137", "--provider", _PROVIDER_ADDR, - "--opt-params", "0x0102", + "set-provider", + "137", + "--provider", + _PROVIDER_ADDR, + "--opt-params", + "0x0102", ], id="set_provider-opt-params-passthrough-s1", ), @@ -401,8 +423,12 @@ def test_set_agent_uri_via_contract_uses_set_uri(): ERC8183_COMPLETE, {"job_id": 137, "reason": b"\x12" * 32, "opt_params": b"\x01"}, [ - "complete", "137", "--reason", "0x" + "12" * 32, - "--opt-params", "0x01", + "complete", + "137", + "--reason", + "0x" + "12" * 32, + "--opt-params", + "0x01", ], id="complete-reason-then-opt-params", ), @@ -496,12 +522,20 @@ def test_create_job_omits_hook_for_zero_address(): "jobId": 138, } assert calls[1] == [ - "twak", "erc8183", "create-job", - "--provider", _PROVIDER_ADDR, - "--evaluator", _EVALUATOR_ADDR, - "--expires-at", "1750000000", # twak's flag name; carries expired_at - "--description", "index the docs", - "--chain", "bsc", "--json", + "twak", + "erc8183", + "create-job", + "--provider", + _PROVIDER_ADDR, + "--evaluator", + _EVALUATOR_ADDR, + "--expires-at", + "1750000000", # twak's flag name; carries expired_at + "--description", + "index the docs", + "--chain", + "bsc", + "--json", ] @@ -524,18 +558,28 @@ def test_create_job_includes_nonzero_hook(): ) assert result["jobId"] == 139 assert calls[1] == [ - "twak", "erc8183", "create-job", - "--provider", _PROVIDER_ADDR, - "--evaluator", _EVALUATOR_ADDR, - "--expires-at", "1750000000", - "--description", "index the docs", - "--hook", _HOOK_ADDR, - "--chain", "bsc", "--json", + "twak", + "erc8183", + "create-job", + "--provider", + _PROVIDER_ADDR, + "--evaluator", + _EVALUATOR_ADDR, + "--expires-at", + "1750000000", + "--description", + "index the docs", + "--hook", + _HOOK_ADDR, + "--chain", + "bsc", + "--json", ] # ── erc8183.fund: --expected-budget atomic pin (gaps S-2, v0.19.0) ── + def _fund_intent(expected_budget, opt_params=b""): return Intent( name=ERC8183_FUND, @@ -548,9 +592,7 @@ def _fund_intent(expected_budget, opt_params=b""): def test_fund_pins_expected_budget_no_status_precheck_and_surfaces_approve_hash(): - run, calls = _intent_router( - {"success": True, "hash": "0xfund", "approveHash": "0xappr"} - ) + run, calls = _intent_router({"success": True, "hash": "0xfund", "approveHash": "0xappr"}) twak = TWAKProvider() result = _execute(twak, _fund_intent(1000), run) assert result == { @@ -563,8 +605,15 @@ def test_fund_pins_expected_budget_no_status_precheck_and_surfaces_approve_hash( # old client-side `erc8183 status` pre-check is gone entirely. assert calls[0] == _WALLET_STATUS_CMD assert calls[1] == [ - "twak", "erc8183", "fund", "137", - "--expected-budget", "1000", "--chain", "bsc", "--json", + "twak", + "erc8183", + "fund", + "137", + "--expected-budget", + "1000", + "--chain", + "bsc", + "--json", ] assert len(calls) == 2 assert all(not (c[1] == "erc8183" and c[2] == "status") for c in calls) @@ -575,9 +624,17 @@ def test_fund_opt_params_passthrough(): twak = TWAKProvider() _execute(twak, _fund_intent(1000, opt_params=b"\xca\xfe"), run) assert calls[1] == [ - "twak", "erc8183", "fund", "137", - "--expected-budget", "1000", "--opt-params", "0xcafe", - "--chain", "bsc", "--json", + "twak", + "erc8183", + "fund", + "137", + "--expected-budget", + "1000", + "--opt-params", + "0xcafe", + "--chain", + "bsc", + "--json", ] @@ -609,6 +666,7 @@ def run(cmd, **kwargs): # ── opt-params passthrough (REQ-1 / S-1, v0.19.0) ── + def test_submit_opt_params_passthrough_req1(): # v0.19.0 (REQ-1): the deliverable_url JSON the SDK facade encodes into # optParams rides the submit tx verbatim — seller role works end-to-end. @@ -625,10 +683,17 @@ def test_submit_opt_params_passthrough_req1(): ) assert result == {"success": True, "transactionHash": "0xfeed", "receipt": None} assert calls[1] == [ - "twak", "erc8183", "submit", "137", - "--deliverable", "0x" + "ab" * 32, - "--opt-params", "0x" + opt.hex(), - "--chain", "bsc", "--json", + "twak", + "erc8183", + "submit", + "137", + "--deliverable", + "0x" + "ab" * 32, + "--opt-params", + "0x" + opt.hex(), + "--chain", + "bsc", + "--json", ] @@ -644,8 +709,17 @@ def test_set_budget_opt_params_passthrough_s1(): run, ) assert calls[1] == [ - "twak", "erc8183", "set-budget", "137", "--amount", "1", - "--opt-params", "0x78", "--chain", "bsc", "--json", + "twak", + "erc8183", + "set-budget", + "137", + "--amount", + "1", + "--opt-params", + "0x78", + "--chain", + "bsc", + "--json", ] @@ -706,9 +780,7 @@ def test_unnamed_intent_rejected(): (ERC8183_VOTE_REJECT, NETWORKS["bsc-mainnet"].policy_contract), ], ) -def test_custom_contract_intent_rejected_before_any_cli_call( - name, canonical_target -): +def test_custom_contract_intent_rejected_before_any_cli_call(name, canonical_target): twak = TWAKProvider() call = types.SimpleNamespace(address="0x" + "99" * 20) with patch("bnbagent.wallets.twak_provider.subprocess.run") as run: @@ -739,9 +811,7 @@ def test_custom_erc8004_target_requires_matching_env_override(name): def test_custom_erc8004_target_allowed_with_matching_env_override(monkeypatch): custom_registry = "0x" + "99" * 20 monkeypatch.setenv("ERC8004_REGISTRY_ADDRESS", custom_registry) - run, calls = _intent_router( - {"success": True, "hash": "0xreg", "agentId": "1362"} - ) + run, calls = _intent_router({"success": True, "hash": "0xreg", "agentId": "1362"}) twak = TWAKProvider() result = _execute( twak, @@ -759,9 +829,7 @@ def test_custom_erc8004_target_allowed_with_matching_env_override(monkeypatch): def test_erc8004_env_override_cannot_redirect_canonical_intent(monkeypatch): monkeypatch.setenv("ERC8004_REGISTRY_ADDRESS", "0x" + "99" * 20) twak = TWAKProvider() - call = types.SimpleNamespace( - address=NETWORKS["bsc-mainnet"].registry_contract - ) + call = types.SimpleNamespace(address=NETWORKS["bsc-mainnet"].registry_contract) with patch("bnbagent.wallets.twak_provider.subprocess.run") as run: with pytest.raises( UnsupportedWalletOperation, @@ -801,6 +869,7 @@ def test_testnet_canonical_contract_target_allowed(): # ── output parse hardening (gaps REQ-3 + error-envelope variants) ── + @pytest.mark.parametrize("field", ["hash", "txHash", "transactionHash"]) def test_tx_hash_field_variants_extracted(field): run, _ = _intent_router({"success": True, field: "0xabc"}) @@ -904,8 +973,14 @@ def test_sign_message_normalises_and_self_checks(): def run(cmd, **kwargs): assert cmd == [ - "twak", "wallet", "sign-message", - "--chain", "bsc", "--message", message, "--json", + "twak", + "wallet", + "sign-message", + "--chain", + "bsc", + "--message", + message, + "--json", ] return _completed(cmd, {"success": True, "signature": raw_sig}) @@ -935,10 +1010,18 @@ def test_sign_message_uses_base_chain_key_on_testnet(): def run(cmd, **kwargs): assert cmd == [ - "twak", "wallet", "sign-message", - "--chain", "bsc", "--message", message, "--json", + "twak", + "wallet", + "sign-message", + "--chain", + "bsc", + "--message", + message, + "--json", ] - return _completed(cmd, {"success": True, "signature": "0x" + bytes(signed.signature).hex()}) + return _completed( + cmd, {"success": True, "signature": "0x" + bytes(signed.signature).hex()} + ) with patch("bnbagent.wallets.twak_provider.subprocess.run", side_effect=run): result = twak.sign_message(message) @@ -951,9 +1034,7 @@ def test_sign_message_recovery_mismatch_raises(): message = "hello twak" acct = Account.from_key(_TEST_KEY) tampered = bytes( - Account.sign_message( - encode_defunct(text=message), private_key="0x" + "cd" * 32 - ).signature + Account.sign_message(encode_defunct(text=message), private_key="0x" + "cd" * 32).signature ).hex() twak = _primed_twak(acct.address) @@ -1011,6 +1092,7 @@ def run(cmd, **kwargs): # ── auto-create / create_wallet (EVM-parity) ── + def _status_router(wallet_exists: bool): """Route wallet status / create / address with a toggleable existence.""" state = {"exists": wallet_exists} @@ -1222,10 +1304,19 @@ def run(cmd, **kwargs): with patch("bnbagent.wallets.twak_provider.subprocess.run", side_effect=run): twak.x402_quote("https://pay.example/x402", method="POST", body='{"amountUsd":0.1}') - assert calls == [[ - "twak", "x402", "quote", "https://pay.example/x402", - "--method", "POST", "--body", '{"amountUsd":0.1}', "--json", - ]] + assert calls == [ + [ + "twak", + "x402", + "quote", + "https://pay.example/x402", + "--method", + "POST", + "--body", + '{"amountUsd":0.1}', + "--json", + ] + ] def test_x402_quote_https_only_error_surfaces(): @@ -1262,8 +1353,14 @@ def test_x402_request_argv_minimal_with_wallet_probe_first(): assert calls[0] == _WALLET_STATUS_CMD # minimal argv: --max-payment + --yes, and no --prefer-*/--method/--body assert calls[1] == [ - "twak", "x402", "request", "https://pay.example/x402", - "--max-payment", "1000", "--yes", "--json", + "twak", + "x402", + "request", + "https://pay.example/x402", + "--max-payment", + "1000", + "--yes", + "--json", ] assert len(calls) == 2 # success output = the endpoint body verbatim (no receipt, gaps S-7) @@ -1286,12 +1383,23 @@ def test_x402_request_argv_includes_prefer_flags_only_when_set(): ) assert calls[1] == [ - "twak", "x402", "request", "https://pay.example/x402", - "--max-payment", str(10**17), "--yes", - "--method", "POST", "--body", '{"amountUsd":0.1}', - "--prefer-network", "eip155:56", - "--prefer-method", "eip3009", - "--prefer-asset", u_asset, + "twak", + "x402", + "request", + "https://pay.example/x402", + "--max-payment", + str(10**17), + "--yes", + "--method", + "POST", + "--body", + '{"amountUsd":0.1}', + "--prefer-network", + "eip155:56", + "--prefer-method", + "eip3009", + "--prefer-asset", + u_asset, "--json", ] @@ -1315,6 +1423,7 @@ def test_x402_request_min_amount_error_surfaces(): # ── live-CLI quirks surfaced by examples/twak (field-verified v0.18.0) ── + def test_exists_false_when_status_reports_not_configured(): # `wallet status` exits 0 even with no wallet; only the agentWallet field # tells the truth. exists() must not be fooled by the zero exit code. @@ -1350,9 +1459,7 @@ def test_run_trusts_success_envelope_over_nonzero_exit(): assert data["accepts"] == [] # an error envelope with a non-zero exit still raises with patch("bnbagent.wallets.twak_provider.subprocess.run") as run: - run.return_value = _completed( - ["twak"], {"error": "boom", "errorCode": "X"}, returncode=1 - ) + run.return_value = _completed(["twak"], {"error": "boom", "errorCode": "X"}, returncode=1) with pytest.raises(RuntimeError, match="boom"): twak.x402_quote("https://www.x402.org/protected") @@ -1367,7 +1474,9 @@ def run(cmd, **kwargs): return _completed(cmd, {"agentWallet": "not configured"}) if "create" in cmd: return _completed( - cmd, {}, returncode=1, + cmd, + {}, + returncode=1, stderr="error: required option '--password ' not specified", ) raise AssertionError(f"unexpected twak command: {cmd}") diff --git a/python/tests/test_wallet.py b/python/tests/test_wallet.py index d8fccaf..fa4b2ab 100644 --- a/python/tests/test_wallet.py +++ b/python/tests/test_wallet.py @@ -2,8 +2,6 @@ Test cases for EVMWalletProvider (~/.bnbagent/wallets/ keystore) """ -import json - import pytest from eth_account import Account @@ -139,7 +137,9 @@ def test_sign_typed_data_eip3009_round_trip(self, wdir): from bnbagent.signing import SigningPolicy wallet = EVMWalletProvider( - password=PW, private_key=PK, wallets_dir=wdir, + password=PW, + private_key=PK, + wallets_dir=wdir, signing_policy=SigningPolicy.permissive(), ) domain = { @@ -187,12 +187,19 @@ def test_sign_typed_data_eip3009_round_trip(self, wdir): def test_sign_typed_data_strips_domain_type_if_supplied(self, wdir): """Caller may pass EIP712Domain entry in types; should produce same sig.""" from bnbagent.signing import SigningPolicy + wallet = EVMWalletProvider( - password=PW, private_key=PK, wallets_dir=wdir, + password=PW, + private_key=PK, + wallets_dir=wdir, signing_policy=SigningPolicy.permissive(), ) - domain = {"name": "Test", "version": "1", "chainId": 56, - "verifyingContract": "0x" + "1" * 40} + domain = { + "name": "Test", + "version": "1", + "chainId": 56, + "verifyingContract": "0x" + "1" * 40, + } types_with_domain = { "EIP712Domain": [ {"name": "name", "type": "string"}, @@ -236,4 +243,3 @@ def test_persist_false_no_file(self, wdir): wallet = EVMWalletProvider(password=PW, private_key=PK, persist=False, wallets_dir=wdir) assert wallet.address == Account.from_key(PK).address assert not wdir.exists() # No directory created - diff --git a/typescript/src/wallets/turnkey/provider.ts b/typescript/src/wallets/turnkey/provider.ts index 83676b1..9f19de0 100644 --- a/typescript/src/wallets/turnkey/provider.ts +++ b/typescript/src/wallets/turnkey/provider.ts @@ -376,6 +376,8 @@ export class TurnkeyWalletProvider extends WalletProvider { /** * Sign a transaction (legacy or EIP-1559 — the serializer infers the type * from the fee fields; both shapes are enclave-verified). + * Legacy transactions return the EIP-155 `v`; typed transactions return + * the wire-format y-parity bit (`0n` or `1n`). * * When the provider was constructed with `expectedChainId`, a mismatching * `tx.chainId` is refused before the billable API call. @@ -395,8 +397,7 @@ export class TurnkeyWalletProvider extends WalletProvider { ); const parsed = parseTransaction(rawTransaction); const v = - parsed.v ?? - (parsed.yParity !== undefined ? BigInt(parsed.yParity) + 27n : 0n); + parsed.type === "legacy" ? (parsed.v ?? 0n) : BigInt(parsed.yParity ?? 0); return { rawTransaction, hash: keccak256(rawTransaction), diff --git a/typescript/src/wallets/walletProvider.ts b/typescript/src/wallets/walletProvider.ts index 597fae8..4270582 100644 --- a/typescript/src/wallets/walletProvider.ts +++ b/typescript/src/wallets/walletProvider.ts @@ -33,7 +33,12 @@ export type SignableTransaction = TransactionRequestLegacy & { chainId: number; }; -/** Result of {@link WalletProvider.signTransaction}. */ +/** + * Result of {@link WalletProvider.signTransaction}. + * + * For legacy transactions, `v` is the EIP-155 value. For typed transactions, + * it is the wire-format y-parity bit (`0n` or `1n`). + */ export interface SignedTx { rawTransaction: `0x${string}`; hash: `0x${string}`; diff --git a/typescript/tests/turnkeyProvider.test.ts b/typescript/tests/turnkeyProvider.test.ts index c15e391..b078d7e 100644 --- a/typescript/tests/turnkeyProvider.test.ts +++ b/typescript/tests/turnkeyProvider.test.ts @@ -503,8 +503,8 @@ describe("signTransaction", () => { const parsed = parseTransaction(signed.rawTransaction); expect(parsed.type).toBe("eip1559"); expect(signed.hash).toBe(keccak256(signed.rawTransaction)); - // Typed transactions carry yParity; the provider normalizes v to 27/28. - expect([27n, 28n]).toContain(signed.v); + // Typed transactions expose the wire-format yParity bit as v. + expect([0n, 1n]).toContain(signed.v); }); it("refuses a chainId mismatch before any billable call when pinned", async () => {