diff --git a/.gitignore b/.gitignore index 1d04dba..e30dc6a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /signed_vp.json /raw_vc.json /decrypted.json +/wr/ # Verifiable Presentation manual-test fixtures — generated, deliberately not committed. # They carry throwaway private keys, and every credential and presentation is bound to the diff --git a/package-lock.json b/package-lock.json index c99d33f..a8775b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,26 @@ { "name": "@trustvc/trustvc-cli", - "version": "1.2.0-beta.2", + "version": "1.3.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustvc/trustvc-cli", - "version": "1.2.0-beta.2", + "version": "1.3.0-beta.1", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "2.16.0-beta.3", + "@trustvc/trustvc": "^2.16.0-beta.4", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", "ethers": "^6.15.0", "inquirer": "^13.1.0", "node-fetch": "^3.3.2", + "ox": "0.14.29", + "permissionless": "0.3.6", "signale": "^1.4.0", + "viem": "2.53.1", "yargs": "^17.7.2" }, "bin": { @@ -112,14 +115,14 @@ "license": "MIT" }, "node_modules/@aws-sdk/client-kms": { - "version": "3.1102.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1102.0.tgz", - "integrity": "sha512-NNWPWGSq3cYGmmqrEx18PfSbC3ZRBDrV/bejOTq/g51Jxf7/rZ0eBhQfJqdTX9GU+MNUuEC3eTGynWTC+Fs9kQ==", + "version": "3.1109.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1109.0.tgz", + "integrity": "sha512-sdS3n7WQdCN3G/Hu+fWJea1KXCgcnW7z78dNZ/q+jQKy4+K2IUhentQ3o2LLlvClTO/MS3+1qbMG/vgoyGgSoQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/credential-provider-node": "^3.972.77", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -131,13 +134,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.5.tgz", - "integrity": "sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ==", + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -150,13 +153,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.66.tgz", - "integrity": "sha512-bOzP2+zdJ0XrghywB4FaJXtGZCx9yS0AGps+VJ5yEgg30wVyHNmVDBwVDXcRypzQY5iLGCS3NSn0nsuISqjFCQ==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -166,13 +169,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.68.tgz", - "integrity": "sha512-lkunS8X+H6V76WE+t/uGQm/U8v0JXK5mLfNFTUAMlE1kqaCjwlmqKJrgCVtqjK/vqnlrSWsLK4Lr4NANBWlfTQ==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -184,20 +187,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.11.tgz", - "integrity": "sha512-KoDEolYtLHG/8C+IiZpXbJWyBOMkrHV+j66Kb9PBXmLv5euGb7aELvuCmLenoGAV6gBW2wM7TsG/1e5iulH4kA==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/credential-provider-env": "^3.972.66", - "@aws-sdk/credential-provider-http": "^3.972.68", - "@aws-sdk/credential-provider-login": "^3.972.73", - "@aws-sdk/credential-provider-process": "^3.972.66", - "@aws-sdk/credential-provider-sso": "^3.973.10", - "@aws-sdk/credential-provider-web-identity": "^3.972.72", - "@aws-sdk/nested-clients": "^3.997.40", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -208,14 +211,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.73.tgz", - "integrity": "sha512-tjsxMkTAFkmiV9ycmymapb9nLECWVOwFs0bZMQ9gB9bnbY8/HwfukHZlWbXZZp7qkPU6EXAfOcMm3DioFFEywA==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/nested-clients": "^3.997.40", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -225,18 +228,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.77", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.77.tgz", - "integrity": "sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw==", + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.66", - "@aws-sdk/credential-provider-http": "^3.972.68", - "@aws-sdk/credential-provider-ini": "^3.973.11", - "@aws-sdk/credential-provider-process": "^3.972.66", - "@aws-sdk/credential-provider-sso": "^3.973.10", - "@aws-sdk/credential-provider-web-identity": "^3.972.72", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -247,13 +250,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.66.tgz", - "integrity": "sha512-YOnX6bIhdjx0QfaENu2PB0eFm5MEc9ft8XNGQ+NxMfeLSq9aE+XjWCwDupEnV4UWv5ZFpBLJbTREIx7KNOoqpQ==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -263,15 +266,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.10.tgz", - "integrity": "sha512-IsXnQ35j5VE+3ZK6aIhT5ypB+Jim3zRwVz0nYuVwyBKZyu/SYx+O2/LQpng8c2EiuwyqceabsDlYrICHDlJPsA==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/nested-clients": "^3.997.40", - "@aws-sdk/token-providers": "3.1102.0", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -281,14 +284,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.72.tgz", - "integrity": "sha512-nj9Zlsy7ya+fy+jhWTJwgfr7YdtDM4xHyZvgKuftuny0UgROVx9lxwvsWJSLvpKk4lig0m0tHng3k1fEnt0LeA==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/nested-clients": "^3.997.40", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -298,14 +301,14 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.40.tgz", - "integrity": "sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ==", + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/signature-v4-multi-region": "^3.996.43", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -317,12 +320,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", - "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/types": "^3.974.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -332,14 +335,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1102.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1102.0.tgz", - "integrity": "sha512-Ua700vVvM1q105yABSUQWkCK6FeTrNfU6ORGetJe5BzkZWY7QhkF7SVTOlmDGWRDNd6jbyY0Dv5e+E4bMBEmLg==", + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.5", - "@aws-sdk/nested-clients": "^3.997.40", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -349,9 +352,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -362,9 +365,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -4062,12 +4065,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.31.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", - "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -4075,13 +4078,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", - "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -4089,13 +4092,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", - "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -4103,13 +4106,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", - "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -4117,13 +4120,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", - "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -4131,9 +4134,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4266,9 +4269,9 @@ }, "node_modules/@tradetrust-tt/token-registry-v5": { "name": "@tradetrust-tt/token-registry", - "version": "5.6.0-beta.1", - "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.6.0-beta.1.tgz", - "integrity": "sha512-EtxZw/WnvGQMb/MB243eHy4ejSTN8bkV7I7Y9f19xk9v/mOojFL74C/tD1zjsZUUjiQi9rzLRYlzSUNbZBN2gg==", + "version": "5.6.0-beta.3", + "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.6.0-beta.3.tgz", + "integrity": "sha512-1YJDuuBN5P2mx94aVrhbQ3wXt0Kb9eqIPjabHeZCkFjH87ZV1rtdXTmy1T2Af8Y7zROCWAD1V/8JpCFqC3u/JA==", "license": "Apache-2.0", "dependencies": { "ethers": "^6.13.4" @@ -4625,9 +4628,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/eip7702": { - "version": "1.0.0-beta.1", - "resolved": "https://registry.npmjs.org/@trustvc/eip7702/-/eip7702-1.0.0-beta.1.tgz", - "integrity": "sha512-cOGuZVLHd0+Uu7rZ/Dp2iF1VuNq+L+pWWGe+tPD/u74rSvCAzIc17dIk5HOwRBiC5awh1rXG46vLG8IRc5O01Q==", + "version": "1.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@trustvc/eip7702/-/eip7702-1.1.0-beta.2.tgz", + "integrity": "sha512-yo82l4fDCE6ZuTPeuDxBSgcRxAUOmVm51y16dJQH4xpM2ZkEKPXfPsMjpiPa636uOEVan+e38v/IQfy2HPg9sA==", "license": "MIT", "dependencies": { "@account-abstraction/contracts": "^0.8.0", @@ -4635,19 +4638,19 @@ } }, "node_modules/@trustvc/trustvc": { - "version": "2.16.0-beta.3", - "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.16.0-beta.3.tgz", - "integrity": "sha512-spuYfT78ZYY5Xg60yTrnDjbGsxwv5QNAyfEhWzp0mcBTRZRPJRiH8mVWTp4AZeAHO6ph9ZyOY0ZVl3f7/cBcvw==", + "version": "2.16.0-beta.4", + "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.16.0-beta.4.tgz", + "integrity": "sha512-KJZUl5LCDX4KqgiBbyK6tUdXoAVxGyyKtJm+kiY8bsnl23h0hI7IfJZlUcgIFaoH8OER4+EI8i5NNDCsiMSOMg==", "license": "Apache-2.0", "dependencies": { "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", "@tradetrust-tt/token-registry-v4": "npm:@tradetrust-tt/token-registry@^4.16.0", - "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.1", + "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.2", "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/eip7702": "^1.0.0-beta.1", + "@trustvc/eip7702": "^1.1.0-beta.2", "@trustvc/w3c": "^2.4.2", "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", @@ -4951,17 +4954,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", - "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/type-utils": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4974,22 +4977,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.66.0", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -5005,14 +5008,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -5027,14 +5030,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5045,9 +5048,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -5062,15 +5065,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", - "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5087,9 +5090,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -5101,16 +5104,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5129,16 +5132,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5153,13 +5156,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5360,13 +5363,13 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/wevm" @@ -5514,9 +5517,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { @@ -5911,8 +5914,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/bundle-require": { "version": "5.1.0", @@ -6114,8 +6117,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "devOptional": true, "license": "ISC", - "optional": true, "engines": { "node": ">=10" } @@ -8391,8 +8394,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -8404,8 +8407,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -10958,8 +10961,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "devOptional": true, "license": "ISC", - "optional": true, "engines": { "node": ">=8" } @@ -10968,8 +10971,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -10982,8 +10985,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11050,9 +11053,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -11316,8 +11319,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "abbrev": "1" }, @@ -13439,9 +13442,9 @@ } }, "node_modules/ox": { - "version": "0.14.33", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", - "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", "funding": [ { "type": "github", @@ -13765,9 +13768,9 @@ "license": "MIT" }, "node_modules/permissionless": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/permissionless/-/permissionless-0.3.7.tgz", - "integrity": "sha512-r1UGWkVMqFzzJ0UcKEVHh6XvOm7+SfTvy7AoGA7ZlxpE96XR+zbtb+rRAA3DOQuq6/ifov5NvUyt1xf4zxbR0g==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/permissionless/-/permissionless-0.3.6.tgz", + "integrity": "sha512-7RstRCNwJc/kO9Q/ZJpgSPclXez8CaGU77PeYNaWpHCU1kRetu+7kqEUFcIFLdz47F7jpOwtsbAU56hn27c4mg==", "license": "MIT", "peerDependencies": { "ox": "^0.11.3", @@ -13911,9 +13914,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -13931,7 +13934,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -14727,9 +14730,9 @@ "integrity": "sha512-yiDCS7tpKQl6p4NG57BdKLTSNLFfj5HosBIzXBl4jZf/qorJzSzbEUIdLhN+vVYgyLlvjixY8DPPTgqI8zvNCA==" }, "node_modules/semantic-release": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz", - "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", + "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", "dependencies": { @@ -15974,8 +15977,8 @@ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -16441,9 +16444,9 @@ } }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": { @@ -16460,9 +16463,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -16477,9 +16480,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -16494,9 +16497,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -16511,9 +16514,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -16528,9 +16531,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -16545,9 +16548,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -16562,9 +16565,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -16579,9 +16582,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -16596,9 +16599,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -16613,9 +16616,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -16630,9 +16633,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -16647,9 +16650,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -16664,9 +16667,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -16681,9 +16684,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -16698,9 +16701,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -16715,9 +16718,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -16732,9 +16735,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -16749,9 +16752,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -16766,9 +16769,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -16783,9 +16786,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -16800,9 +16803,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -16817,9 +16820,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -16834,9 +16837,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -16851,9 +16854,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -16868,9 +16871,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -16885,9 +16888,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -16902,9 +16905,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -16915,32 +16918,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/tunnel": { @@ -17406,8 +17409,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "builtins": "^1.0.3" } @@ -17436,9 +17439,9 @@ } }, "node_modules/viem": { - "version": "2.55.10", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.10.tgz", - "integrity": "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ==", + "version": "2.53.1", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.53.1.tgz", + "integrity": "sha512-FhfJ/SW73CVosiyVLmIMVgKDRKYV1AGCLzZoHYvmNayyVff63Qi1ocPCk59LqC/cNw244RbBJjHnmxqXkE7NpA==", "funding": [ { "type": "github", @@ -17453,8 +17456,8 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.33", - "ws": "8.21.0" + "ox": "0.14.29", + "ws": "8.20.1" }, "peerDependencies": { "typescript": ">=5.0.4" @@ -17492,10 +17495,31 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/viem/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index bb1e92b..75810d4 100644 --- a/package.json +++ b/package.json @@ -35,16 +35,24 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "2.16.0-beta.3", + "@trustvc/trustvc": "^2.16.0-beta.4", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", "ethers": "^6.15.0", "inquirer": "^13.1.0", "node-fetch": "^3.3.2", + "ox": "0.14.29", + "permissionless": "0.3.6", "signale": "^1.4.0", + "viem": "2.53.1", "yargs": "^17.7.2" }, + "overrides": { + "permissionless": { + "ox": "$ox" + } + }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", diff --git a/src/commands/gasless/admin/add-authorized-caller.ts b/src/commands/gasless/admin/add-authorized-caller.ts new file mode 100644 index 0000000..98d6f8d --- /dev/null +++ b/src/commands/gasless/admin/add-authorized-caller.ts @@ -0,0 +1,29 @@ +import { error } from 'signale'; +import { addAuthorizedCaller } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'add-authorized-caller'; + +export const describe = + 'Authorizes an address to trigger sponsored holder/beneficiary title-escrow or registry calls (Path A) on a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const caller = await promptAddress('caller', 'address to authorize on the paymaster'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Authorizing caller ${caller}`, + execute: (wallet) => + addAuthorizedCaller( + wallet, + base.paymasterAddress as `0x${string}`, + caller as `0x${string}`, + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/add-registry.ts b/src/commands/gasless/admin/add-registry.ts new file mode 100644 index 0000000..3619913 --- /dev/null +++ b/src/commands/gasless/admin/add-registry.ts @@ -0,0 +1,25 @@ +import { error } from 'signale'; +import { addRegistry } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'add-registry'; + +export const describe = + 'Authorizes a token registry so its calls can be sponsored by a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const registry = await promptAddress('registry', 'token registry address to authorize'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Authorizing registry ${registry}`, + execute: (wallet) => + addRegistry(wallet, base.paymasterAddress as `0x${string}`, registry as `0x${string}`), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/add-title-escrow.ts b/src/commands/gasless/admin/add-title-escrow.ts new file mode 100644 index 0000000..37e91b2 --- /dev/null +++ b/src/commands/gasless/admin/add-title-escrow.ts @@ -0,0 +1,29 @@ +import { error } from 'signale'; +import { addTitleEscrow } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'add-title-escrow'; + +export const describe = + 'Authorizes a title escrow so its calls can be sponsored by a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const titleEscrow = await promptAddress('title escrow', 'title escrow address to authorize'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Authorizing title escrow ${titleEscrow}`, + execute: (wallet) => + addTitleEscrow( + wallet, + base.paymasterAddress as `0x${string}`, + titleEscrow as `0x${string}`, + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/common.ts b/src/commands/gasless/admin/common.ts new file mode 100644 index 0000000..6376f74 --- /dev/null +++ b/src/commands/gasless/admin/common.ts @@ -0,0 +1,85 @@ +import { error, info, success } from 'signale'; +import { + getErrorMessage, + getEtherscanAddress, + getWalletOrSigner, + promptAddress, + promptNetworkSelection, + promptWalletSelection, +} from '../../../utils'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { GaslessWalletOption } from '../types'; + +/** + * PlatformPaymaster admin functions (add/remove authorized caller, registry, title escrow; + * user whitelist credits; daily limit) are all owner-only, regular (non-gasless) transactions — + * the owner pays gas directly to configure their own paymaster. + */ +export type PaymasterAdminWalletCommand = GaslessWalletOption & { + network: string; + paymasterAddress: string; +}; + +export const promptForPaymasterAdminWalletInputs = + async (): Promise => { + const network = await promptNetworkSelection(); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract to administer', + ); + + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + return { + network, + paymasterAddress: paymasterAddress as string, + encryptedWalletPath, + key, + keyFile, + }; + }; + +export interface RunPaymasterAdminActionArgs extends PaymasterAdminWalletCommand { + actionLabel: string; + /** Wallet is a raw ethers Signer here, not a viem WalletClient — no smart account involved. */ + execute: (wallet: Awaited>) => Promise<`0x${string}`>; +} + +/** + * Shared runner for every paymaster admin action: resolves a regular wallet, runs the given + * on-chain call, and reports the transaction hash. Errors are caught and logged here, so callers + * only need to guard their own prompting step. + */ +export const runPaymasterAdminAction = async ({ + network, + paymasterAddress, + encryptedWalletPath, + key, + keyFile, + actionLabel, + execute, +}: RunPaymasterAdminActionArgs): Promise => { + try { + const assertedNetwork = assertGaslessSupportedNetwork(network); + + const wallet = await getWalletOrSigner({ + network: assertedNetwork, + encryptedWalletPath, + key, + keyFile, + }); + + info(`${actionLabel} on PlatformPaymaster ${paymasterAddress}...`); + + const txHash = await execute(wallet); + + success(`${actionLabel} — done`); + info(`Find more details at ${getEtherscanAddress({ network: assertedNetwork })}/tx/${txHash}`); + + return txHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/admin/delegate-user.ts b/src/commands/gasless/admin/delegate-user.ts new file mode 100644 index 0000000..8d50045 --- /dev/null +++ b/src/commands/gasless/admin/delegate-user.ts @@ -0,0 +1,82 @@ +import { error, info, success } from 'signale'; +import { createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { + getErrorMessage, + getEtherscanAddress, + getWalletOrSigner, + promptNetworkSelection, + promptWalletSelection, +} from '../../../utils'; +import { + assertGaslessSupportedNetwork, + getEip7702ImplementationAddress, + getGaslessRpcUrl, + getViemChain, + redactPimlicoApiKey, +} from '../config'; + +// Submits a standalone EIP-7702 authorization for a user's own EOA — the first of the three +// setup steps ("Delegate" in the README) needed before an account can act as a smart account. +// Regular, non-gasless transaction: the user pays gas to delegate their own account. Every gasless +// command already bundles this automatically with its first sponsored UserOperation, so running +// this separately is only needed to set delegation up ahead of time. +export const command = 'delegate-user'; + +export const describe = + "Delegates a user's EOA to the deployed EIP7702Implementation contract via a standalone EIP-7702 authorization. Regular transaction — the user pays gas to delegate their own account."; + +export const handler = async (): Promise => { + try { + const network = await promptNetworkSelection(); + const assertedNetwork = assertGaslessSupportedNetwork(network); + + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + // Only used to recover the raw private key: viem needs a LocalAccount to sign the + // authorization, not an ethers Signer. + const wallet = await getWalletOrSigner({ + network: assertedNetwork, + encryptedWalletPath, + key, + keyFile, + }); + + const privateKey = (wallet as { privateKey?: string }).privateKey; + if (!privateKey) { + throw new Error( + 'Delegating requires direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + } + + const implementationAddress = getEip7702ImplementationAddress(assertedNetwork); + const account = privateKeyToAccount(privateKey as `0x${string}`); + const walletClient = createWalletClient({ + account, + chain: getViemChain(assertedNetwork), + transport: http(getGaslessRpcUrl(assertedNetwork)), + }); + + info(`Delegating ${account.address} to ${implementationAddress} on ${assertedNetwork}...`); + + // `executor: 'self'` tells viem the account signing the authorization is also the one + // submitting the transaction, so it correctly uses nonce + 1 rather than the current nonce. + const authorization = await walletClient.signAuthorization({ + contractAddress: implementationAddress, + executor: 'self', + }); + + const txHash = await walletClient.sendTransaction({ + authorizationList: [authorization], + to: account.address, + value: 0n, + }); + + success(`Account ${account.address} delegated to ${implementationAddress}`); + info(`Find more details at ${getEtherscanAddress({ network: assertedNetwork })}/tx/${txHash}`); + + return txHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/admin/fund-paymaster.ts b/src/commands/gasless/admin/fund-paymaster.ts new file mode 100644 index 0000000..8dc715e --- /dev/null +++ b/src/commands/gasless/admin/fund-paymaster.ts @@ -0,0 +1,45 @@ +import { error } from 'signale'; +import { input } from '@inquirer/prompts'; +import { ethers } from 'ethers'; +import { eip7702Abis } from '@trustvc/trustvc'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'fund-paymaster'; + +export const describe = + "Deposits ETH into a PlatformPaymaster's EntryPoint gas balance so it can sponsor UserOperations. Regular transaction — the caller pays the deposited amount plus gas."; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + + const amountEth = await input({ + message: 'Enter the amount to deposit in ETH:', + required: true, + validate: (value: string) => { + if (!/^\d*\.?\d+$/.test(value) || Number(value) <= 0) { + return 'Amount must be a positive number (ETH)'; + } + return true; + }, + }); + const amount = ethers.parseEther(amountEth); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Depositing ${amountEth} ETH`, + execute: async (wallet) => { + const contract = new ethers.Contract( + base.paymasterAddress, + eip7702Abis.platformPaymasterAbi, + wallet, + ); + const tx = await contract.deposit({ value: amount }); + const receipt = await tx.wait(); + return receipt.hash as `0x${string}`; + }, + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/index.ts b/src/commands/gasless/admin/index.ts new file mode 100644 index 0000000..d26b587 --- /dev/null +++ b/src/commands/gasless/admin/index.ts @@ -0,0 +1,11 @@ +import { Argv } from 'yargs'; + +export const command = 'paymaster-admin '; + +export const describe = + 'Administer a PlatformPaymaster contract (owner-only; regular, non-gasless transactions)'; + +export const builder = (yargs: Argv): Argv => + yargs.commandDir(__dirname, { extensions: ['ts', 'js'] }); + +export const handler = (): void => {}; diff --git a/src/commands/gasless/admin/remove-authorized-caller.ts b/src/commands/gasless/admin/remove-authorized-caller.ts new file mode 100644 index 0000000..20d0864 --- /dev/null +++ b/src/commands/gasless/admin/remove-authorized-caller.ts @@ -0,0 +1,29 @@ +import { error } from 'signale'; +import { removeAuthorizedCaller } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'remove-authorized-caller'; + +export const describe = + 'Removes an address from the authorized-caller list (Path A) on a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const caller = await promptAddress('caller', 'address to remove from the paymaster'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Removing caller ${caller}`, + execute: (wallet) => + removeAuthorizedCaller( + wallet, + base.paymasterAddress as `0x${string}`, + caller as `0x${string}`, + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/remove-registry.ts b/src/commands/gasless/admin/remove-registry.ts new file mode 100644 index 0000000..5fc8afa --- /dev/null +++ b/src/commands/gasless/admin/remove-registry.ts @@ -0,0 +1,24 @@ +import { error } from 'signale'; +import { removeRegistry } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'remove-registry'; + +export const describe = 'Deauthorizes a token registry from a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const registry = await promptAddress('registry', 'token registry address to deauthorize'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Deauthorizing registry ${registry}`, + execute: (wallet) => + removeRegistry(wallet, base.paymasterAddress as `0x${string}`, registry as `0x${string}`), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/remove-title-escrow.ts b/src/commands/gasless/admin/remove-title-escrow.ts new file mode 100644 index 0000000..455888f --- /dev/null +++ b/src/commands/gasless/admin/remove-title-escrow.ts @@ -0,0 +1,28 @@ +import { error } from 'signale'; +import { removeTitleEscrow } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'remove-title-escrow'; + +export const describe = 'Deauthorizes a title escrow from a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const titleEscrow = await promptAddress('title escrow', 'title escrow address to deauthorize'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Deauthorizing title escrow ${titleEscrow}`, + execute: (wallet) => + removeTitleEscrow( + wallet, + base.paymasterAddress as `0x${string}`, + titleEscrow as `0x${string}`, + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/remove-user-from-whitelist.ts b/src/commands/gasless/admin/remove-user-from-whitelist.ts new file mode 100644 index 0000000..9275805 --- /dev/null +++ b/src/commands/gasless/admin/remove-user-from-whitelist.ts @@ -0,0 +1,29 @@ +import { error } from 'signale'; +import { removeUserFromWhitelist } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'remove-user-from-whitelist'; + +export const describe = + 'Removes a user from the PlatformPaymaster whitelist (sets their deployment credits to 0)'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const user = await promptAddress('user', 'address to remove from the whitelist'); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Removing user ${user} from whitelist`, + execute: (wallet) => + removeUserFromWhitelist( + wallet, + base.paymasterAddress as `0x${string}`, + user as `0x${string}`, + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/set-daily-limit.ts b/src/commands/gasless/admin/set-daily-limit.ts new file mode 100644 index 0000000..e2183f0 --- /dev/null +++ b/src/commands/gasless/admin/set-daily-limit.ts @@ -0,0 +1,35 @@ +import { error } from 'signale'; +import { input } from '@inquirer/prompts'; +import { setDailyLimit } from '@trustvc/trustvc'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'set-daily-limit'; + +export const describe = + 'Sets the global daily sponsored-gas spend limit per user on a PlatformPaymaster'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + + const dailyLimit = await input({ + message: 'Enter the new daily limit in wei (0 = unlimited):', + required: true, + validate: (value: string) => { + if (!/^\d+$/.test(value)) { + return 'Daily limit must be a non-negative integer (wei)'; + } + return true; + }, + }); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Setting daily limit to ${dailyLimit} wei`, + execute: (wallet) => + setDailyLimit(wallet, base.paymasterAddress as `0x${string}`, BigInt(dailyLimit)), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/set-user-whitelist.ts b/src/commands/gasless/admin/set-user-whitelist.ts new file mode 100644 index 0000000..b59fa79 --- /dev/null +++ b/src/commands/gasless/admin/set-user-whitelist.ts @@ -0,0 +1,45 @@ +import { error } from 'signale'; +import { input } from '@inquirer/prompts'; +import { setUserWhitelist } from '@trustvc/trustvc'; +import { promptAddress } from '../../../utils'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'set-user-whitelist'; + +export const describe = + 'Whitelists a user on a PlatformPaymaster and sets their token-registry deployment credit allowance (0-3)'; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + const user = await promptAddress( + 'user', + 'address to whitelist for gasless registry deployment', + ); + + const credits = await input({ + message: 'Enter the deployment credit allowance (0-3):', + required: true, + validate: (value: string) => { + if (!/^[0-3]$/.test(value)) { + return 'Credits must be an integer between 0 and 3'; + } + return true; + }, + }); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Whitelisting user ${user} with ${credits} credit(s)`, + execute: (wallet) => + setUserWhitelist( + wallet, + base.paymasterAddress as `0x${string}`, + user as `0x${string}`, + BigInt(credits), + ), + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/admin/stake-paymaster.ts b/src/commands/gasless/admin/stake-paymaster.ts new file mode 100644 index 0000000..8a4ee22 --- /dev/null +++ b/src/commands/gasless/admin/stake-paymaster.ts @@ -0,0 +1,56 @@ +import { error } from 'signale'; +import { input } from '@inquirer/prompts'; +import { ethers } from 'ethers'; +import { eip7702Abis } from '@trustvc/trustvc'; +import { promptForPaymasterAdminWalletInputs, runPaymasterAdminAction } from './common'; + +export const command = 'stake-paymaster'; + +export const describe = + "Stakes ETH for a PlatformPaymaster on the EntryPoint — an anti-spam bond many bundlers (including Pimlico) require before they'll accept UserOperations sponsored by it. Regular transaction — the caller pays the staked amount plus gas."; + +export const handler = async (): Promise => { + try { + const base = await promptForPaymasterAdminWalletInputs(); + + const amountEth = await input({ + message: 'Enter the amount to stake in ETH:', + required: true, + validate: (value: string) => { + if (!/^\d*\.?\d+$/.test(value) || Number(value) <= 0) { + return 'Amount must be a positive number (ETH)'; + } + return true; + }, + }); + const amount = ethers.parseEther(amountEth); + + const unstakeDelaySec = await input({ + message: 'Enter the unstake delay in seconds (optional, defaults to 86400 = 1 day):', + default: '86400', + validate: (value: string) => { + if (!/^\d+$/.test(value) || Number(value) <= 0) { + return 'Unstake delay must be a positive integer (seconds)'; + } + return true; + }, + }); + + return await runPaymasterAdminAction({ + ...base, + actionLabel: `Staking ${amountEth} ETH with a ${unstakeDelaySec}s unstake delay`, + execute: async (wallet) => { + const contract = new ethers.Contract( + base.paymasterAddress, + eip7702Abis.platformPaymasterAbi, + wallet, + ); + const tx = await contract.addStake(Number(unstakeDelaySec), { value: amount }); + const receipt = await tx.wait(); + return receipt.hash as `0x${string}`; + }, + }); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/client.ts b/src/commands/gasless/client.ts new file mode 100644 index 0000000..9eae6c9 --- /dev/null +++ b/src/commands/gasless/client.ts @@ -0,0 +1,98 @@ +import { createPublicClient, Hex, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { entryPoint08Address } from 'viem/account-abstraction'; +import { to7702SimpleSmartAccount } from 'permissionless/accounts'; +import { createSmartAccountClient } from 'permissionless'; +import { createPimlicoClient } from 'permissionless/clients/pimlico'; +import { + GaslessSupportedNetwork, + getEip7702ImplementationAddress, + getGaslessRpcUrl, + getPimlicoApiKey, + getPimlicoBundlerUrl, + getViemChain, +} from './config'; + +/** + * Minimal client shape required by @trustvc/trustvc's `*Gasless` functions: given the raw + * call, sign and submit a sponsored UserOperation and resolve once it has been mined. + */ +export interface GaslessSmartAccountClient { + sendTransaction(args: { + to: `0x${string}`; + value: bigint; + data: `0x${string}`; + }): Promise<`0x${string}`>; +} + +export interface BuildGaslessSmartAccountClientArgs { + network: GaslessSupportedNetwork; + /** Raw private key (hex) of the current holder/beneficiary EOA that will delegate via EIP-7702. */ + privateKey: string; + paymasterAddress: `0x${string}`; +} + +export interface GaslessSmartAccountClientResult { + smartAccountClient: GaslessSmartAccountClient; + smartAccountAddress: `0x${string}`; +} + +// Conservative stub limits used while the bundler estimates gas for the sponsored UserOperation. +const PAYMASTER_VERIFICATION_GAS_LIMIT = 150_000n; +const PAYMASTER_POST_OP_GAS_LIMIT = 50_000n; + +/** + * Builds an EIP-7702 smart account client (holder's EOA delegated to the deployed + * EIP7702Implementation contract) wired to Pimlico's bundler, with gas sponsored on-chain by the + * PlatformPaymaster at `paymasterAddress`. + */ +export const buildGaslessSmartAccountClient = async ({ + network, + privateKey, + paymasterAddress, +}: BuildGaslessSmartAccountClientArgs): Promise => { + const chain = getViemChain(network); + const rpcUrl = getGaslessRpcUrl(network); + const implementationAddress = getEip7702ImplementationAddress(network); + const bundlerUrl = getPimlicoBundlerUrl(network, getPimlicoApiKey()); + + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }); + const owner = privateKeyToAccount(privateKey as Hex); + + const smartAccount = await to7702SimpleSmartAccount({ + client: publicClient, + owner, + accountLogicAddress: implementationAddress, + }); + + const pimlicoClient = createPimlicoClient({ + transport: http(bundlerUrl), + entryPoint: { address: entryPoint08Address, version: '0.8' }, + }); + + const smartAccountClient = createSmartAccountClient({ + account: smartAccount, + chain, + bundlerTransport: http(bundlerUrl), + paymaster: { + getPaymasterStubData: async () => ({ + paymaster: paymasterAddress, + paymasterData: '0x', + paymasterVerificationGasLimit: PAYMASTER_VERIFICATION_GAS_LIMIT, + paymasterPostOpGasLimit: PAYMASTER_POST_OP_GAS_LIMIT, + }), + getPaymasterData: async () => ({ + paymaster: paymasterAddress, + paymasterData: '0x', + }), + }, + userOperation: { + estimateFeesPerGas: async () => (await pimlicoClient.getUserOperationGasPrice()).fast, + }, + }); + + return { + smartAccountClient, + smartAccountAddress: smartAccount.address, + }; +}; diff --git a/src/commands/gasless/common.ts b/src/commands/gasless/common.ts new file mode 100644 index 0000000..397efeb --- /dev/null +++ b/src/commands/gasless/common.ts @@ -0,0 +1,202 @@ +import { warn, success } from 'signale'; +import { ethers } from 'ethers'; +import { v5Contracts } from '@trustvc/trustvc'; +import { getSupportedNetwork, getWalletOrSigner } from '../../utils'; +import { assertGaslessSupportedNetwork, GaslessSupportedNetwork } from './config'; +import { buildGaslessSmartAccountClient, GaslessSmartAccountClient } from './client'; +import { checkGaslessEligibility } from './eligibility'; +import { GaslessWalletOption } from './types'; + +const { TitleEscrow__factory, TradeTrustToken__factory } = v5Contracts; + +/** Title escrow role(s) the connected wallet must currently hold to run a given gasless action. */ +export type TitleEscrowRole = 'holder' | 'beneficiary'; + +export const resolveTitleEscrowAddress = async ({ + tokenRegistryAddress, + tokenId, + network, +}: { + tokenRegistryAddress: string; + tokenId: string; + network: string; +}): Promise => { + const provider = getSupportedNetwork(network).provider(); + const tokenRegistry = new ethers.Contract( + tokenRegistryAddress, + TradeTrustToken__factory.abi, + provider, + ); + const titleEscrowAddress: string = await tokenRegistry.ownerOf(tokenId); + + if (!titleEscrowAddress || titleEscrowAddress === ethers.ZeroAddress) { + throw new Error(`Unable to resolve the title escrow address for tokenId ${tokenId}`); + } + + return titleEscrowAddress; +}; + +export type PrepareGaslessRunArgs = GaslessWalletOption & { + network: string; + tokenRegistryAddress: string; + tokenId: string; + paymasterAddress: string; + /** Role(s) the connected wallet must currently hold on the title escrow (checked before submitting). */ + requiredRoles: TitleEscrowRole[]; +}; + +export interface GaslessRunContext { + network: GaslessSupportedNetwork; + titleEscrowAddress: `0x${string}`; + callerAddress: `0x${string}`; + smartAccountClient: GaslessSmartAccountClient; +} + +/** + * Shared setup for every gasless title-escrow action: resolves the title escrow, recovers the + * caller's raw private key, verifies the caller currently holds the required role(s), runs the + * paymaster eligibility check, then builds the EIP-7702 smart account client. + */ +export const prepareGaslessRun = async ({ + network, + tokenRegistryAddress, + tokenId, + paymasterAddress, + encryptedWalletPath, + key, + keyFile, + requiredRoles, +}: PrepareGaslessRunArgs): Promise => { + const assertedNetwork = assertGaslessSupportedNetwork(network); + + const titleEscrowAddress = await resolveTitleEscrowAddress({ + tokenRegistryAddress, + tokenId, + network: assertedNetwork, + }); + + // This wallet is only used to recover the raw private key for the smart-account owner; no + // transaction is ever signed or sent with it directly. + const wallet = await getWalletOrSigner({ + network: assertedNetwork, + encryptedWalletPath, + key, + keyFile, + }); + + const privateKey = (wallet as { privateKey?: string }).privateKey; + if (!privateKey) { + throw new Error( + 'Gasless transactions require direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + } + + const callerAddress = await wallet.getAddress(); + + const titleEscrow = new ethers.Contract( + titleEscrowAddress, + TitleEscrow__factory.abi, + getSupportedNetwork(assertedNetwork).provider(), + ); + + for (const role of requiredRoles) { + const currentRoleHolder: string = await titleEscrow[role](); + if (currentRoleHolder.toLowerCase() !== callerAddress.toLowerCase()) { + throw new Error( + `The connected wallet (${callerAddress}) is not the current ${role} (${currentRoleHolder}) of this transferable record.`, + ); + } + } + + warn('Checking whether this account is eligible for a gasless (sponsored) transaction...'); + await checkGaslessEligibility({ + network: assertedNetwork, + paymasterAddress: paymasterAddress as `0x${string}`, + callerAddress: callerAddress as `0x${string}`, + titleEscrowAddress: titleEscrowAddress as `0x${string}`, + }); + success('Account is eligible for a gasless transaction'); + + const { smartAccountClient } = await buildGaslessSmartAccountClient({ + network: assertedNetwork, + privateKey, + paymasterAddress: paymasterAddress as `0x${string}`, + }); + + return { + network: assertedNetwork, + titleEscrowAddress: titleEscrowAddress as `0x${string}`, + callerAddress: callerAddress as `0x${string}`, + smartAccountClient, + }; +}; + +export type PrepareGaslessRegistryRunArgs = GaslessWalletOption & { + network: string; + tokenRegistryAddress: string; + paymasterAddress: string; +}; + +export interface GaslessRegistryRunContext { + network: GaslessSupportedNetwork; + callerAddress: `0x${string}`; + smartAccountClient: GaslessSmartAccountClient; +} + +/** + * Shared setup for gasless actions that operate directly on the token registry contract rather + * than a specific title escrow (e.g. accepting/rejecting a returned document, which the registry + * admin performs). Recovers the caller's raw private key, runs the paymaster eligibility check + * against the registry address, then builds the EIP-7702 smart account client. Unlike + * `prepareGaslessRun`, there is no title-escrow role to check here — the registry contract itself + * enforces who may call these functions. + */ +export const prepareGaslessRegistryRun = async ({ + network, + tokenRegistryAddress, + paymasterAddress, + encryptedWalletPath, + key, + keyFile, +}: PrepareGaslessRegistryRunArgs): Promise => { + const assertedNetwork = assertGaslessSupportedNetwork(network); + + // This wallet is only used to recover the raw private key for the smart-account owner; no + // transaction is ever signed or sent with it directly. + const wallet = await getWalletOrSigner({ + network: assertedNetwork, + encryptedWalletPath, + key, + keyFile, + }); + + const privateKey = (wallet as { privateKey?: string }).privateKey; + if (!privateKey) { + throw new Error( + 'Gasless transactions require direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + } + + const callerAddress = await wallet.getAddress(); + + warn('Checking whether this account is eligible for a gasless (sponsored) transaction...'); + await checkGaslessEligibility({ + network: assertedNetwork, + paymasterAddress: paymasterAddress as `0x${string}`, + callerAddress: callerAddress as `0x${string}`, + registryAddress: tokenRegistryAddress as `0x${string}`, + }); + success('Account is eligible for a gasless transaction'); + + const { smartAccountClient } = await buildGaslessSmartAccountClient({ + network: assertedNetwork, + privateKey, + paymasterAddress: paymasterAddress as `0x${string}`, + }); + + return { + network: assertedNetwork, + callerAddress: callerAddress as `0x${string}`, + smartAccountClient, + }; +}; diff --git a/src/commands/gasless/config.ts b/src/commands/gasless/config.ts new file mode 100644 index 0000000..4664c58 --- /dev/null +++ b/src/commands/gasless/config.ts @@ -0,0 +1,124 @@ +import { Chain } from 'viem'; +import { polygonAmoy, sepolia } from 'viem/chains'; +import { gaslessConstants } from '@trustvc/trustvc'; +import { NetworkCmdName } from '../../utils'; + +/** + * Gasless (EIP-7702 + Pimlico sponsored) transactions depend on a PlatformPaymaster and an + * EIP7702Implementation contract being deployed on the target chain. Today those are only + * deployed on Sepolia and Amoy, so gasless support is intentionally limited to these networks. + */ +export const GASLESS_SUPPORTED_NETWORKS = [NetworkCmdName.Sepolia, NetworkCmdName.Amoy] as const; + +export type GaslessSupportedNetwork = (typeof GASLESS_SUPPORTED_NETWORKS)[number]; + +export const isGaslessSupportedNetwork = (network: string): network is GaslessSupportedNetwork => + (GASLESS_SUPPORTED_NETWORKS as readonly string[]).includes(network); + +export const assertGaslessSupportedNetwork = (network: string): GaslessSupportedNetwork => { + if (!isGaslessSupportedNetwork(network)) { + throw new Error( + `Gasless transactions are only supported on: ${GASLESS_SUPPORTED_NETWORKS.join(', ')}. ` + + `The document resolved to network "${network}", which is not supported. Re-run without --gasless to send a regular transaction.`, + ); + } + return network; +}; + +const VIEM_CHAIN: Record = { + [NetworkCmdName.Sepolia]: sepolia, + [NetworkCmdName.Amoy]: polygonAmoy, +}; + +export const getViemChain = (network: GaslessSupportedNetwork): Chain => VIEM_CHAIN[network]; + +const DEFAULT_RPC_URL: Record = { + [NetworkCmdName.Sepolia]: 'https://ethereum-sepolia-rpc.publicnode.com', + [NetworkCmdName.Amoy]: 'https://polygon-amoy-bor-rpc.publicnode.com', +}; + +/** Reads the Pimlico API key from the environment. Required for every gasless transaction. */ +export const getPimlicoApiKey = (): string => { + const apiKey = process.env.PIMLICO_API_KEY; + if (!apiKey) { + throw new Error( + 'PIMLICO_API_KEY environment variable is required for gasless transactions. ' + + 'Get an API key from https://dashboard.pimlico.io and set it in your environment.', + ); + } + return apiKey; +}; + +/** + * Reads the deployed EIP7702Implementation contract address for the given network from the + * environment. Checked first as `{NETWORK}_EIP7702_IMPL_ADDRESS`, falling back to the generic + * `EIP7702_IMPL_ADDRESS` if set. + */ +export const getEip7702ImplementationAddress = ( + network: GaslessSupportedNetwork, +): `0x${string}` => { + const scopedEnvVar = `${network.toUpperCase()}_EIP7702_IMPL_ADDRESS`; + const address = process.env[scopedEnvVar] || process.env.EIP7702_IMPL_ADDRESS; + + if (!address) { + throw new Error( + `Missing EIP-7702 implementation address for ${network}. Set the ${scopedEnvVar} ` + + `(or generic EIP7702_IMPL_ADDRESS) environment variable to the deployed EIP7702Implementation contract address.`, + ); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + throw new Error( + `Invalid EIP-7702 implementation address in ${scopedEnvVar}/EIP7702_IMPL_ADDRESS: ${address}`, + ); + } + + return address as `0x${string}`; +}; + +/** RPC URL used for gasless reads/client setup, overridable via `{NETWORK}_RPC`. */ +export const getGaslessRpcUrl = (network: GaslessSupportedNetwork): string => { + const scopedEnvVar = `${network.toUpperCase()}_RPC`; + return process.env[scopedEnvVar] || DEFAULT_RPC_URL[network]; +}; + +// Pimlico's v2 bundler/paymaster endpoint accepts a chain identifier segment; the numeric chain +// ID is used here (rather than a hand-maintained slug table) since it works for any chain and +// never goes stale as Pimlico adds/renames network slugs. +export const getPimlicoBundlerUrl = (network: GaslessSupportedNetwork, apiKey: string): string => + `https://api.pimlico.io/v2/${getViemChain(network).id}/rpc?apikey=${apiKey}`; + +/** + * viem's HTTP/RPC/timeout errors embed the request URL verbatim in their message (it only strips + * basic-auth credentials, not query params), so a bundler network error would otherwise leak the + * `apikey` query value from `getPimlicoBundlerUrl` straight into command-handler logs. Strips it + * from arbitrary error text before logging. + */ +export const redactPimlicoApiKey = (message: string): string => { + const apiKey = process.env.PIMLICO_API_KEY; + return apiKey ? message.split(apiKey).join('[REDACTED]') : message; +}; + +/** + * PlatformAccountFactory address for the given network, from @trustvc/trustvc's own + * `gaslessConstants`. Deliberately not left for `deployPlatformPaymaster` to default itself — + * its internal fallback (`@trustvc/eip7702`'s bundled constants) is stale for Sepolia and has no + * entry at all for Amoy. Looked up lazily (not at module load) so this file stays importable + * even against a partial `@trustvc/trustvc` mock that doesn't stub `gaslessConstants`. + */ +export const getGaslessFactoryAddress = (network: GaslessSupportedNetwork): `0x${string}` => { + const factoryAddress: Record = { + [NetworkCmdName.Sepolia]: gaslessConstants.GASLESS_FACTORY_ADDRESS_SEPOLIA, + [NetworkCmdName.Amoy]: gaslessConstants.GASLESS_FACTORY_ADDRESS_AMOY, + }; + + const address = factoryAddress[network]; + if (!address) { + throw new Error( + `Missing gasless factory address for ${network} in @trustvc/trustvc's gaslessConstants. ` + + `This indicates an incompatible @trustvc/trustvc version — check for a package update.`, + ); + } + + return address as `0x${string}`; +}; diff --git a/src/commands/gasless/deploy/deploy-platform-paymaster.ts b/src/commands/gasless/deploy/deploy-platform-paymaster.ts new file mode 100644 index 0000000..e47e712 --- /dev/null +++ b/src/commands/gasless/deploy/deploy-platform-paymaster.ts @@ -0,0 +1,129 @@ +import { Argv } from 'yargs'; +import { error, info, success } from 'signale'; +import { input } from '@inquirer/prompts'; +import { id } from 'ethers'; +import { deployPlatformPaymaster } from '@trustvc/trustvc'; +import { + getErrorMessage, + getEtherscanAddress, + getSupportedNetwork, + getWalletOrSigner, + promptAddress, + promptNetworkSelection, + promptWalletSelection, +} from '../../../utils'; +import { + assertGaslessSupportedNetwork, + getGaslessFactoryAddress, + redactPimlicoApiKey, +} from '../config'; +import { GaslessWalletOption } from '../types'; + +// Deploys the PlatformPaymaster itself — this is a regular, non-gasless transaction. Someone has +// to pay real gas to bootstrap the paymaster before it can sponsor anything else. +export const command = 'deploy-platform-paymaster'; + +export const describe = + 'Deploys a PlatformPaymaster contract that sponsors gasless title-escrow and token-registry transactions. This is a regular transaction — you pay gas directly to deploy it.'; + +export const builder = (yargs: Argv): Argv => yargs; + +export type DeployPlatformPaymasterCommand = GaslessWalletOption & { + network: string; + salt: string; + platformAddress?: string; + dailyLimit?: string; +}; + +export const promptForDeployPlatformPaymasterInputs = + async (): Promise => { + const network = await promptNetworkSelection(); + assertGaslessSupportedNetwork(network); + + const saltInput = await input({ + message: + 'Enter a salt to deterministically derive the paymaster address (0x-prefixed 32-byte hex, or any string — it will be hashed into one):', + required: true, + validate: (value: string) => { + if (value.startsWith('0x') && !/^0x[a-fA-F0-9]{64}$/.test(value)) { + return 'A 0x-prefixed salt must be a 32-byte (64 hex character) value'; + } + return true; + }, + }); + + // A 0x-prefixed value is used as-is (already validated above); anything else is hashed into + // a deterministic bytes32 value. + const salt = saltInput.startsWith('0x') ? saltInput : id(saltInput); + + const platformAddress = await promptAddress( + 'platform owner', + 'defaults to your own deployer address if left blank', + true, + ); + + const dailyLimit = await input({ + message: 'Enter the daily sponsored-gas limit in wei (optional, 0 = unlimited):', + required: false, + default: '0', + validate: (value: string) => { + if (value && !/^\d+$/.test(value)) { + return 'Daily limit must be a non-negative integer (wei)'; + } + return true; + }, + }); + + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + return { + network, + salt, + platformAddress, + dailyLimit: dailyLimit || undefined, + encryptedWalletPath, + key, + keyFile, + }; + }; + +export const runDeployPlatformPaymaster = async ( + args: DeployPlatformPaymasterCommand, +): Promise => { + try { + const network = assertGaslessSupportedNetwork(args.network); + + const wallet = await getWalletOrSigner({ + network, + encryptedWalletPath: args.encryptedWalletPath, + key: args.key, + keyFile: args.keyFile, + }); + + info(`Deploying PlatformPaymaster on ${network}...`); + + const { txHash, paymasterAddress } = await deployPlatformPaymaster(wallet, { + chainId: getSupportedNetwork(network).networkId, + salt: args.salt as `0x${string}`, + platformAddress: args.platformAddress as `0x${string}` | undefined, + factoryAddress: getGaslessFactoryAddress(network), + dailyLimit: args.dailyLimit ? BigInt(args.dailyLimit) : undefined, + }); + + success(`PlatformPaymaster deployed at ${paymasterAddress}`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${txHash}`); + + return paymasterAddress; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; + +export const handler = async (): Promise => { + try { + const answers = await promptForDeployPlatformPaymasterInputs(); + return await runDeployPlatformPaymaster(answers); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + } +}; diff --git a/src/commands/gasless/deploy/deploy-token-registry-gasless.ts b/src/commands/gasless/deploy/deploy-token-registry-gasless.ts new file mode 100644 index 0000000..96eb570 --- /dev/null +++ b/src/commands/gasless/deploy/deploy-token-registry-gasless.ts @@ -0,0 +1,155 @@ +import { error, info, success, warn } from 'signale'; +import { input } from '@inquirer/prompts'; +import { createPublicClient, http, parseEventLogs } from 'viem'; +import { deployTokenRegistryGasless, eip7702Abis, v5ContractAddress } from '@trustvc/trustvc'; +import { + getErrorMessage, + getEtherscanAddress, + getWalletOrSigner, + promptAddress, + promptNetworkSelection, + promptWalletSelection, +} from '../../../utils'; +import { + assertGaslessSupportedNetwork, + getGaslessRpcUrl, + getViemChain, + redactPimlicoApiKey, +} from '../config'; +import { buildGaslessSmartAccountClient } from '../client'; +import { checkGaslessDeployEligibility } from '../eligibility'; +import { GaslessWalletOption } from '../types'; + +// Deploys a new token registry gaslessly via the PlatformPaymaster's deployRegistry(). Wired into +// `token-registry deploy --gasless` (see ../../token-registry/deploy.ts) rather than being its +// own top-level command, matching every other gasless action. +export type DeployTokenRegistryGaslessCommand = GaslessWalletOption & { + network: string; + registryName: string; + registrySymbol: string; + paymasterAddress: string; + tokenRegistryImplAddress: string; +}; + +export const promptForDeployTokenRegistryGaslessInputs = + async (): Promise => { + const network = await promptNetworkSelection(); + const assertedNetwork = assertGaslessSupportedNetwork(network); + + const registryName = await input({ + message: 'Enter the name of the token registry:', + required: true, + validate: (value: string) => (value.trim() === '' ? 'Registry name is required' : true), + }); + + const registrySymbol = await input({ + message: 'Enter the symbol of the token registry:', + required: true, + validate: (value: string) => (value.trim() === '' ? 'Registry symbol is required' : true), + }); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this deployment', + ); + + const chainId = getViemChain(assertedNetwork).id; + const defaultImplAddress = v5ContractAddress.TokenImplementation[chainId]; + const tokenRegistryImplAddress = await promptAddress( + 'token registry implementation', + defaultImplAddress + ? `optional — defaults to ${defaultImplAddress} for this network` + : 'the deployed TradeTrustToken implementation contract to clone', + Boolean(defaultImplAddress), + ); + + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + + return { + network, + registryName, + registrySymbol, + paymasterAddress: paymasterAddress as string, + tokenRegistryImplAddress: (tokenRegistryImplAddress ?? defaultImplAddress) as string, + encryptedWalletPath, + key, + keyFile, + }; + }; + +export const runDeployTokenRegistryGasless = async ( + args: DeployTokenRegistryGaslessCommand, +): Promise => { + try { + const network = assertGaslessSupportedNetwork(args.network); + + // This wallet is only used to recover the raw private key for the smart-account owner; no + // transaction is ever signed or sent with it directly. + const wallet = await getWalletOrSigner({ + network, + encryptedWalletPath: args.encryptedWalletPath, + key: args.key, + keyFile: args.keyFile, + }); + + const privateKey = (wallet as { privateKey?: string }).privateKey; + if (!privateKey) { + throw new Error( + 'Gasless transactions require direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + } + + const callerAddress = await wallet.getAddress(); + + warn('Checking whether this account is eligible to deploy gaslessly...'); + await checkGaslessDeployEligibility({ + network, + paymasterAddress: args.paymasterAddress as `0x${string}`, + callerAddress: callerAddress as `0x${string}`, + }); + success('Account is eligible to deploy gaslessly'); + + const { smartAccountClient } = await buildGaslessSmartAccountClient({ + network, + privateKey, + paymasterAddress: args.paymasterAddress as `0x${string}`, + }); + + info( + `Submitting gasless deployment of token registry ${args.registryName} (${args.registrySymbol}). Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await deployTokenRegistryGasless( + args.registryName, + args.registrySymbol, + smartAccountClient, + { + paymasterAddress: args.paymasterAddress as `0x${string}`, + tokenRegistryImplAddress: args.tokenRegistryImplAddress as `0x${string}`, + }, + ); + + const publicClient = createPublicClient({ + chain: getViemChain(network), + transport: http(getGaslessRpcUrl(network)), + }); + const receipt = await publicClient.getTransactionReceipt({ hash: transactionHash }); + const [deployedEvent] = parseEventLogs({ + abi: eip7702Abis.platformPaymasterAbi, + logs: receipt.logs, + eventName: 'RegistryDeployed', + }); + const deployedAddress = deployedEvent?.args?.deployed; + + if (deployedAddress) { + success(`Token registry ${args.registryName} deployed at ${deployedAddress}`); + } else { + success(`Token registry ${args.registryName} deployment submitted`); + } + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return deployedAddress ?? transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/eligibility.ts b/src/commands/gasless/eligibility.ts new file mode 100644 index 0000000..f87a3fb --- /dev/null +++ b/src/commands/gasless/eligibility.ts @@ -0,0 +1,253 @@ +import { createPublicClient, http } from 'viem'; +import { eip7702Abis, v5Contracts, v5RoleHash } from '@trustvc/trustvc'; +import { GaslessSupportedNetwork, getGaslessRpcUrl, getViemChain } from './config'; + +export type CheckGaslessEligibilityArgs = { + network: GaslessSupportedNetwork; + paymasterAddress: `0x${string}`; + callerAddress: `0x${string}`; +} & ( + | { titleEscrowAddress: `0x${string}`; registryAddress?: never } + | { titleEscrowAddress?: never; registryAddress: `0x${string}` } +); + +/** + * Verifies that the PlatformPaymaster will actually sponsor this transaction before we build a + * smart account client and submit a UserOperation. Checks, against the paymaster contract: + * - it is a deployed contract at the given address + * - the title escrow (or, for registry-level actions, the token registry) being acted on is authorized + * - the caller is an authorized caller + * - the caller's daily sponsored-gas spend limit has not been reached + * - the paymaster has a non-zero deposit at the EntryPoint to draw gas from + * + * Throws a descriptive error naming the failed check; does nothing on success. + */ +export const checkGaslessEligibility = async ({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + registryAddress, +}: CheckGaslessEligibilityArgs): Promise => { + const publicClient = createPublicClient({ + chain: getViemChain(network), + transport: http(getGaslessRpcUrl(network)), + }); + const abi = eip7702Abis.platformPaymasterAbi; + + const paymasterCode = await publicClient.getCode({ address: paymasterAddress }); + if (!paymasterCode || paymasterCode === '0x') { + throw new Error( + `No PlatformPaymaster contract found at ${paymasterAddress} on ${network}. Check the address and try again.`, + ); + } + + const [isActedOnAddressAuthorized, isCallerAuthorized, dailySpend, deposit] = await Promise.all([ + publicClient.readContract( + titleEscrowAddress + ? { + address: paymasterAddress, + abi, + functionName: 'authorizedTitleEscrows', + args: [titleEscrowAddress], + } + : { + address: paymasterAddress, + abi, + functionName: 'authorizedRegistries', + args: [registryAddress], + }, + ), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'authorizedCallers', + args: [callerAddress], + }), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'getUserDailySpend', + args: [callerAddress], + }), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'getDeposit', + }), + ]); + + if (!isActedOnAddressAuthorized) { + throw new Error( + titleEscrowAddress + ? `This account cannot perform a gasless transaction: title escrow ${titleEscrowAddress} is not ` + + `authorized on the PlatformPaymaster (${paymasterAddress}). Ask the paymaster owner to call addTitleEscrow first.` + : `This account cannot perform a gasless transaction: token registry ${registryAddress} is not ` + + `authorized on the PlatformPaymaster (${paymasterAddress}). Ask the paymaster owner to call addRegistry first.`, + ); + } + + if (!isCallerAuthorized) { + throw new Error( + `This account cannot perform a gasless transaction: caller ${callerAddress} is not an authorized ` + + `caller on the PlatformPaymaster (${paymasterAddress}). Ask the paymaster owner to call addAuthorizedCaller first.`, + ); + } + + const [spent, limit] = dailySpend; + if (limit > 0n && spent >= limit) { + throw new Error( + `This account cannot perform a gasless transaction: daily sponsored-gas limit reached for ` + + `${callerAddress} (spent ${spent.toString()} of ${limit.toString()} wei). Try again after the daily limit resets.`, + ); + } + + if ((deposit as unknown as bigint) <= 0n) { + throw new Error( + `This account cannot perform a gasless transaction: the PlatformPaymaster (${paymasterAddress}) has ` + + `no ETH deposited at the EntryPoint, so it cannot sponsor gas. Ask the paymaster owner to fund it.`, + ); + } +}; + +export interface CheckGaslessDeployEligibilityArgs { + network: GaslessSupportedNetwork; + paymasterAddress: `0x${string}`; + callerAddress: `0x${string}`; +} + +/** + * Verifies eligibility for `deployRegistry` on the PlatformPaymaster ("Path B" — credit-gated, + * unrelated to `authorizedCallers`/`authorizedTitleEscrows`/`authorizedRegistries`). Checks: + * - it is a deployed contract at the given address + * - the caller has at least one deployment credit (`userWhitelist`) + * - the paymaster has a non-zero deposit at the EntryPoint to draw gas from + * + * Throws a descriptive error naming the failed check; does nothing on success. + */ +export const checkGaslessDeployEligibility = async ({ + network, + paymasterAddress, + callerAddress, +}: CheckGaslessDeployEligibilityArgs): Promise => { + const publicClient = createPublicClient({ + chain: getViemChain(network), + transport: http(getGaslessRpcUrl(network)), + }); + const abi = eip7702Abis.platformPaymasterAbi; + + const paymasterCode = await publicClient.getCode({ address: paymasterAddress }); + if (!paymasterCode || paymasterCode === '0x') { + throw new Error( + `No PlatformPaymaster contract found at ${paymasterAddress} on ${network}. Check the address and try again.`, + ); + } + + const [credits, deposit] = await Promise.all([ + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'userWhitelist', + args: [callerAddress], + }), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'getDeposit', + }), + ]); + + if ((credits as unknown as bigint) <= 0n) { + throw new Error( + `This account cannot deploy a token registry gaslessly: caller ${callerAddress} has no deployment ` + + `credits on the PlatformPaymaster (${paymasterAddress}). Ask the paymaster owner to call setUserWhitelist first.`, + ); + } + + if ((deposit as unknown as bigint) <= 0n) { + throw new Error( + `This account cannot deploy a token registry gaslessly: the PlatformPaymaster (${paymasterAddress}) has ` + + `no ETH deposited at the EntryPoint, so it cannot sponsor gas. Ask the paymaster owner to fund it.`, + ); + } +}; + +export interface CheckGaslessMintEligibilityArgs { + network: GaslessSupportedNetwork; + paymasterAddress: `0x${string}`; + registryAddress: `0x${string}`; +} + +/** + * Verifies eligibility for `mintDocument` on the PlatformPaymaster ("Path B" — `mintDocument` has + * no caller restriction of its own; it just requires `authorizedRegistries[registry]` and then + * calls `registry.mint(...)`, which succeeds only if the token registry has granted the + * PlatformPaymaster contract itself the `MINTER_ROLE`. Granting that role is the token registry + * owner's call, not the paymaster owner's). Checks: + * - it is a deployed contract at the given address + * - the token registry being minted on is authorized + * - the token registry has granted the PlatformPaymaster the `MINTER_ROLE` + * - the paymaster has a non-zero deposit at the EntryPoint to draw gas from + * + * Throws a descriptive error naming the failed check; does nothing on success. + */ +export const checkGaslessMintEligibility = async ({ + network, + paymasterAddress, + registryAddress, +}: CheckGaslessMintEligibilityArgs): Promise => { + const publicClient = createPublicClient({ + chain: getViemChain(network), + transport: http(getGaslessRpcUrl(network)), + }); + const abi = eip7702Abis.platformPaymasterAbi; + + const paymasterCode = await publicClient.getCode({ address: paymasterAddress }); + if (!paymasterCode || paymasterCode === '0x') { + throw new Error( + `No PlatformPaymaster contract found at ${paymasterAddress} on ${network}. Check the address and try again.`, + ); + } + + const [isPaymasterMinter, isRegistryAuthorized, deposit] = await Promise.all([ + publicClient.readContract({ + address: registryAddress, + abi: v5Contracts.TradeTrustToken__factory.abi, + functionName: 'hasRole', + args: [v5RoleHash.MinterRole, paymasterAddress], + }), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'authorizedRegistries', + args: [registryAddress], + }), + publicClient.readContract({ + address: paymasterAddress, + abi, + functionName: 'getDeposit', + }), + ]); + + if (!isPaymasterMinter) { + throw new Error( + `This account cannot mint gaslessly: the PlatformPaymaster (${paymasterAddress}) does not hold ` + + `MINTER_ROLE on token registry ${registryAddress}. Ask the token registry's owner to grant it ` + + `(e.g. via grantRole).`, + ); + } + + if (!isRegistryAuthorized) { + throw new Error( + `This account cannot mint gaslessly: token registry ${registryAddress} is not authorized on the ` + + `PlatformPaymaster (${paymasterAddress}). Ask the paymaster owner to call addRegistry first.`, + ); + } + + if ((deposit as unknown as bigint) <= 0n) { + throw new Error( + `This account cannot mint gaslessly: the PlatformPaymaster (${paymasterAddress}) has no ETH deposited ` + + `at the EntryPoint, so it cannot sponsor gas. Ask the paymaster owner to fund it.`, + ); + } +}; diff --git a/src/commands/gasless/index.ts b/src/commands/gasless/index.ts new file mode 100644 index 0000000..b9d6877 --- /dev/null +++ b/src/commands/gasless/index.ts @@ -0,0 +1,20 @@ +// Shared gasless (EIP-7702 + Pimlico sponsored) transaction utilities and handlers. +// Not a yargs command directory: no module here exports `command`/`handler`, so main.ts's +// recursive commandDir scan skips this folder entirely. +export * from './config'; +export * from './client'; +export * from './eligibility'; +export * from './types'; +export * from './common'; +export * from './title-escrow/transfer-holder'; +export * from './title-escrow/transfer-beneficiary'; +export * from './title-escrow/nominate'; +export * from './title-escrow/transfer-owners'; +export * from './title-escrow/reject-transfer-holder'; +export * from './title-escrow/reject-transfer-beneficiary'; +export * from './title-escrow/reject-transfer-owners'; +export * from './title-escrow/return-to-issuer'; +export * from './title-escrow/accept-return-to-issuer'; +export * from './title-escrow/reject-return-to-issuer'; +export * from './token-regitsry/mint'; +export * from './deploy/deploy-token-registry-gasless'; diff --git a/src/commands/gasless/title-escrow/accept-return-to-issuer.ts b/src/commands/gasless/title-escrow/accept-return-to-issuer.ts new file mode 100644 index 0000000..c2e62ad --- /dev/null +++ b/src/commands/gasless/title-escrow/accept-return-to-issuer.ts @@ -0,0 +1,83 @@ +import { error, info, success } from 'signale'; +import { acceptReturnedGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRegistryRun } from '../common'; +import { TokenRegistryReturnedDocumentGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly accept a returned transferable record. */ +export const promptForGaslessAcceptReturnToIssuerInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless accept-return-to-issuer transaction end to end: this acts directly on the token + * registry contract (the registry admin burns the token) rather than a title escrow, so there is + * no title-escrow role check — the on-chain contract enforces who may call it. Recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `acceptReturnedGasless`. + */ +export const runAcceptReturnToIssuerGasless = async ( + args: TokenRegistryReturnedDocumentGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // acceptReturnedGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + const { network, smartAccountClient } = await prepareGaslessRegistryRun(args); + + info( + `Submitting gasless acceptance of the returned document with tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await acceptReturnedGasless( + { tokenRegistryAddress: args.tokenRegistryAddress }, + smartAccountClient, + { tokenId: args.tokenId, remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success(`Returned transferable record with tokenId ${args.tokenId} has been accepted.`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/nominate.ts b/src/commands/gasless/title-escrow/nominate.ts new file mode 100644 index 0000000..6db8432 --- /dev/null +++ b/src/commands/gasless/title-escrow/nominate.ts @@ -0,0 +1,92 @@ +import { error, info, success } from 'signale'; +import { nominateGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowNominateBeneficiaryGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly nominate a new beneficiary for a transferable record. */ +export const promptForGaslessNominateInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const newBeneficiary = await promptAddress('beneficiary', 'new beneficiary (owner)'); + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newBeneficiary: newBeneficiary as string, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless beneficiary nomination end to end: resolves the title escrow, recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `nominateGasless`. + */ +export const runNominateGasless = async ( + args: TitleEscrowNominateBeneficiaryGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // nominateGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Nominating requires the caller to currently be the beneficiary (mirrors the non-gasless flow). + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['beneficiary'], + }); + + info( + `Submitting gasless nomination for tokenId ${args.tokenId} to new owner at ${args.newBeneficiary}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await nominateGasless( + { titleEscrowAddress }, + smartAccountClient, + { newBeneficiaryAddress: args.newBeneficiary, remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId} has successfully nominated a new beneficiary with address ${args.newBeneficiary}`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/reject-return-to-issuer.ts b/src/commands/gasless/title-escrow/reject-return-to-issuer.ts new file mode 100644 index 0000000..cb13c96 --- /dev/null +++ b/src/commands/gasless/title-escrow/reject-return-to-issuer.ts @@ -0,0 +1,83 @@ +import { error, info, success } from 'signale'; +import { rejectReturnedGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRegistryRun } from '../common'; +import { TokenRegistryReturnedDocumentGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly reject a returned transferable record. */ +export const promptForGaslessRejectReturnToIssuerInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless reject-return-to-issuer transaction end to end: this acts directly on the token + * registry contract (the registry admin restores the token) rather than a title escrow, so there + * is no title-escrow role check — the on-chain contract enforces who may call it. Recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `rejectReturnedGasless`. + */ +export const runRejectReturnToIssuerGasless = async ( + args: TokenRegistryReturnedDocumentGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // rejectReturnedGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + const { network, smartAccountClient } = await prepareGaslessRegistryRun(args); + + info( + `Submitting gasless rejection of the returned document with tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await rejectReturnedGasless( + { tokenRegistryAddress: args.tokenRegistryAddress }, + smartAccountClient, + { tokenId: args.tokenId, remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success(`Returned transferable record with tokenId ${args.tokenId} has been rejected.`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/reject-transfer-beneficiary.ts b/src/commands/gasless/title-escrow/reject-transfer-beneficiary.ts new file mode 100644 index 0000000..d19bfb9 --- /dev/null +++ b/src/commands/gasless/title-escrow/reject-transfer-beneficiary.ts @@ -0,0 +1,90 @@ +import { error, info, success } from 'signale'; +import { rejectTransferBeneficiaryGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowRejectTransferGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly reject a pending owner (beneficiary) transfer. */ +export const promptForGaslessRejectTransferOwnerInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless reject-transfer-owner transaction end to end: resolves the title escrow, + * recovers the caller's raw private key, checks the account is eligible for sponsorship, then + * submits the sponsored UserOperation via @trustvc/trustvc's `rejectTransferBeneficiaryGasless`. + */ +export const runRejectTransferOwnerGasless = async ( + args: TitleEscrowRejectTransferGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // rejectTransferBeneficiaryGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Rejecting an owner (beneficiary) transfer requires the caller to currently be the beneficiary. + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['beneficiary'], + }); + + info( + `Submitting gasless rejection of the pending owner transfer for tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await rejectTransferBeneficiaryGasless( + { titleEscrowAddress }, + smartAccountClient, + { remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s owner transfer has been successfully rejected to previous owner`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/reject-transfer-holder.ts b/src/commands/gasless/title-escrow/reject-transfer-holder.ts new file mode 100644 index 0000000..70637d5 --- /dev/null +++ b/src/commands/gasless/title-escrow/reject-transfer-holder.ts @@ -0,0 +1,90 @@ +import { error, info, success } from 'signale'; +import { rejectTransferHolderGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowRejectTransferGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly reject a pending holder transfer. */ +export const promptForGaslessRejectTransferHolderInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless reject-transfer-holder transaction end to end: resolves the title escrow, + * recovers the caller's raw private key, checks the account is eligible for sponsorship, then + * submits the sponsored UserOperation via @trustvc/trustvc's `rejectTransferHolderGasless`. + */ +export const runRejectTransferHolderGasless = async ( + args: TitleEscrowRejectTransferGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // rejectTransferHolderGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Rejecting a holder transfer requires the caller to currently be the holder. + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder'], + }); + + info( + `Submitting gasless rejection of the pending holder transfer for tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await rejectTransferHolderGasless( + { titleEscrowAddress }, + smartAccountClient, + { remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s holder transfer has been successfully rejected to previous holder`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/reject-transfer-owners.ts b/src/commands/gasless/title-escrow/reject-transfer-owners.ts new file mode 100644 index 0000000..e5a924e --- /dev/null +++ b/src/commands/gasless/title-escrow/reject-transfer-owners.ts @@ -0,0 +1,90 @@ +import { error, info, success } from 'signale'; +import { rejectTransferOwnersGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowRejectTransferGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly reject a pending owner-and-holder transfer. */ +export const promptForGaslessRejectTransferOwnerHolderInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless reject-transfer-owner-holder transaction end to end: resolves the title escrow, + * recovers the caller's raw private key, checks the account is eligible for sponsorship, then + * submits the sponsored UserOperation via @trustvc/trustvc's `rejectTransferOwnersGasless`. + */ +export const runRejectTransferOwnerHolderGasless = async ( + args: TitleEscrowRejectTransferGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // rejectTransferOwnersGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Rejecting an owner+holder transfer requires the caller to currently be both. + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder', 'beneficiary'], + }); + + info( + `Submitting gasless rejection of the pending owner and holder transfer for tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await rejectTransferOwnersGasless( + { titleEscrowAddress }, + smartAccountClient, + { remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s owner and holder transfer has been successfully rejected to previous owner and holder`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/return-to-issuer.ts b/src/commands/gasless/title-escrow/return-to-issuer.ts new file mode 100644 index 0000000..497e8fb --- /dev/null +++ b/src/commands/gasless/title-escrow/return-to-issuer.ts @@ -0,0 +1,88 @@ +import { error, info, success } from 'signale'; +import { returnToIssuerGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowReturnDocumentGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly return a transferable record to the issuer. */ +export const promptForGaslessReturnToIssuerInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless return-to-issuer transaction end to end: resolves the title escrow, recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `returnToIssuerGasless`. + */ +export const runReturnToIssuerGasless = async ( + args: TitleEscrowReturnDocumentGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // returnToIssuerGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Returning to the issuer requires the caller to currently be both the holder and beneficiary. + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder', 'beneficiary'], + }); + + info( + `Submitting gasless return to issuer for tokenId ${args.tokenId}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await returnToIssuerGasless( + { titleEscrowAddress }, + smartAccountClient, + { remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success(`Transferable record with tokenId ${args.tokenId} has been returned.`); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/transfer-beneficiary.ts b/src/commands/gasless/title-escrow/transfer-beneficiary.ts new file mode 100644 index 0000000..1ccce77 --- /dev/null +++ b/src/commands/gasless/title-escrow/transfer-beneficiary.ts @@ -0,0 +1,92 @@ +import { error, info, success } from 'signale'; +import { transferBeneficiaryGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowTransferBeneficiaryGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly endorse (transfer) the beneficiary of a transferable record. */ +export const promptForGaslessTransferBeneficiaryInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const newBeneficiary = await promptAddress('new beneficiary', 'new owner'); + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newBeneficiary: newBeneficiary as string, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless beneficiary transfer end to end: resolves the title escrow, recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `transferBeneficiaryGasless`. + */ +export const runTransferBeneficiaryGasless = async ( + args: TitleEscrowTransferBeneficiaryGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // transferBeneficiaryGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // transferBeneficiary requires the caller to currently be the holder (mirrors the non-gasless flow). + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder'], + }); + + info( + `Submitting gasless beneficiary transfer for tokenId ${args.tokenId} to ${args.newBeneficiary}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await transferBeneficiaryGasless( + { titleEscrowAddress }, + smartAccountClient, + { newBeneficiaryAddress: args.newBeneficiary, remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s holder has been successfully endorsed to approved beneficiary at ${args.newBeneficiary}`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/transfer-holder.ts b/src/commands/gasless/title-escrow/transfer-holder.ts new file mode 100644 index 0000000..d0c1d25 --- /dev/null +++ b/src/commands/gasless/title-escrow/transfer-holder.ts @@ -0,0 +1,91 @@ +import { error, info, success } from 'signale'; +import { transferHolderGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowTransferHolderGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly transfer the holder of a transferable record. */ +export const promptForGaslessTransferHolderInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const newHolder = await promptAddress('new holder', 'new holder'); + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newHolder: newHolder as string, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless transfer-holder transaction end to end: resolves the title escrow, recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `transferHolderGasless`. + */ +export const runTransferHolderGasless = async ( + args: TitleEscrowTransferHolderGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // transferHolderGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder'], + }); + + info( + `Submitting gasless transfer of holder for tokenId ${args.tokenId} to ${args.newHolder}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await transferHolderGasless( + { titleEscrowAddress }, + smartAccountClient, + { holderAddress: args.newHolder, remarks: args.remark }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s holder has been successfully changed to holder with address: ${args.newHolder}`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/title-escrow/transfer-owners.ts b/src/commands/gasless/title-escrow/transfer-owners.ts new file mode 100644 index 0000000..9779738 --- /dev/null +++ b/src/commands/gasless/title-escrow/transfer-owners.ts @@ -0,0 +1,98 @@ +import { error, info, success } from 'signale'; +import { transferOwnersGasless } from '@trustvc/trustvc'; +import { + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { prepareGaslessRun } from '../common'; +import { TitleEscrowTransferOwnersGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly transfer both the owner and holder of a transferable record. */ +export const promptForGaslessTransferOwnersInputs = + async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const newOwner = await promptAddress('new owner', 'new beneficiary'); + const newHolder = await promptAddress('new holder', 'new holder'); + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + newOwner: newOwner as string, + newHolder: newHolder as string, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; + }; + +/** + * Runs a gasless owner+holder transfer end to end: resolves the title escrow, recovers the + * caller's raw private key, checks the account is eligible for sponsorship, then submits the + * sponsored UserOperation via @trustvc/trustvc's `transferOwnersGasless`. + */ +export const runTransferOwnersGasless = async ( + args: TitleEscrowTransferOwnersGaslessCommand, +): Promise => { + try { + // Validates remark length up front; the encrypted value returned here is unused because + // transferOwnersGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + info( + `Resolving title escrow for tokenId ${args.tokenId} on registry ${args.tokenRegistryAddress}`, + ); + // Requires the caller to currently be both the holder and the beneficiary (mirrors the non-gasless flow). + const { network, titleEscrowAddress, smartAccountClient } = await prepareGaslessRun({ + ...args, + requiredRoles: ['holder', 'beneficiary'], + }); + + info( + `Submitting gasless transfer of owner and holder for tokenId ${args.tokenId} to new owner at ${args.newOwner} and new holder at ${args.newHolder}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await transferOwnersGasless( + { titleEscrowAddress }, + smartAccountClient, + { + newBeneficiaryAddress: args.newOwner, + newHolderAddress: args.newHolder, + remarks: args.remark, + }, + { id: args.encryptionKey }, + ); + + success( + `Transferable record with tokenId ${args.tokenId}'s holder has been successfully endorsed to new owner with address ${args.newOwner} and new holder with address: ${args.newHolder}`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/token-regitsry/mint.ts b/src/commands/gasless/token-regitsry/mint.ts new file mode 100644 index 0000000..9f3ecbc --- /dev/null +++ b/src/commands/gasless/token-regitsry/mint.ts @@ -0,0 +1,127 @@ +import { error, info, success, warn } from 'signale'; +import { mintGasless } from '@trustvc/trustvc'; +import { + addAddressPrefix, + extractDocumentInfo, + getErrorMessage, + getEtherscanAddress, + getWalletOrSigner, + promptAddress, + promptAndReadDocument, + promptRemark, + promptWalletSelection, + verifyDocumentSignature, +} from '../../../utils'; +import { validateAndEncryptRemark } from '../../helpers'; +import { assertGaslessSupportedNetwork, redactPimlicoApiKey } from '../config'; +import { buildGaslessSmartAccountClient } from '../client'; +import { checkGaslessMintEligibility } from '../eligibility'; +import { TokenRegistryMintGaslessCommand } from '../types'; + +/** Prompt for all inputs needed to gaslessly mint a hash to a token registry. */ +export const promptForGaslessMintInputs = async (): Promise => { + const document = await promptAndReadDocument(); + await verifyDocumentSignature(document); + + const { tokenRegistry, tokenId, network, documentId, registryVersion } = + await extractDocumentInfo(document); + assertGaslessSupportedNetwork(network); + + const beneficiary = await promptAddress('beneficiary', 'initial recipient'); + const holder = await promptAddress('holder', 'initial holder'); + const paymasterAddress = await promptAddress( + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + const { encryptedWalletPath, key, keyFile } = await promptWalletSelection(); + const remark = await promptRemark(registryVersion); + + return { + network, + tokenRegistryAddress: tokenRegistry, + tokenId, + beneficiary: beneficiary as string, + holder: holder as string, + paymasterAddress: paymasterAddress as string, + remark, + encryptionKey: documentId, + encryptedWalletPath, + key, + keyFile, + }; +}; + +/** + * Runs a gasless mint end to end. Unlike title-escrow gasless actions, minting acts directly on + * the PlatformPaymaster (mintDocument), which has no title escrow to resolve beforehand and is + * gated by whether the token registry has granted the PlatformPaymaster the MINTER_ROLE, rather + * than an authorized-caller list. Recovers the caller's raw private key, checks the account is + * eligible for sponsorship, then submits the sponsored UserOperation via @trustvc/trustvc's + * `mintGasless`. + */ +export const runMintGasless = async ( + args: TokenRegistryMintGaslessCommand, +): Promise => { + try { + const network = assertGaslessSupportedNetwork(args.network); + + // Validates remark length up front; the encrypted value returned here is unused because + // mintGasless() re-derives and encrypts the remark itself from the raw string. + validateAndEncryptRemark(args.remark, args.encryptionKey); + + // This wallet is only used to recover the raw private key for the smart-account owner; no + // transaction is ever signed or sent with it directly. + const wallet = await getWalletOrSigner({ + network, + encryptedWalletPath: args.encryptedWalletPath, + key: args.key, + keyFile: args.keyFile, + }); + + const privateKey = (wallet as { privateKey?: string }).privateKey; + if (!privateKey) { + throw new Error( + 'Gasless transactions require direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + } + + warn('Checking whether this account is eligible for a gasless (sponsored) transaction...'); + await checkGaslessMintEligibility({ + network, + paymasterAddress: args.paymasterAddress as `0x${string}`, + registryAddress: args.tokenRegistryAddress as `0x${string}`, + }); + success('Account is eligible for a gasless transaction'); + + const { smartAccountClient } = await buildGaslessSmartAccountClient({ + network, + privateKey, + paymasterAddress: args.paymasterAddress as `0x${string}`, + }); + + info( + `Submitting gasless mint of tokenId ${args.tokenId} to registry ${args.tokenRegistryAddress} with beneficiary ${args.beneficiary} and holder ${args.holder}. Gas is sponsored by the PlatformPaymaster — no ETH is required from your wallet.`, + ); + + const transactionHash = await mintGasless( + { paymasterAddress: args.paymasterAddress, tokenRegistryAddress: args.tokenRegistryAddress }, + smartAccountClient, + { + beneficiaryAddress: args.beneficiary, + holderAddress: args.holder, + tokenId: addAddressPrefix(args.tokenId), + remarks: args.remark, + }, + { id: args.encryptionKey }, + ); + + success( + `Token with hash ${args.tokenId} has been minted on ${args.tokenRegistryAddress} with the initial recipient being ${args.beneficiary} and initial holder ${args.holder}`, + ); + info(`Find more details at ${getEtherscanAddress({ network })}/tx/${transactionHash}`); + + return transactionHash; + } catch (e) { + error(redactPimlicoApiKey(getErrorMessage(e))); + } +}; diff --git a/src/commands/gasless/types.ts b/src/commands/gasless/types.ts new file mode 100644 index 0000000..1025871 --- /dev/null +++ b/src/commands/gasless/types.ts @@ -0,0 +1,63 @@ +/** + * Gasless transactions sign an EIP-7702 authorization and a UserOperation locally, so only wallet + * inputs that expose a raw private key are supported (no AWS KMS remote signer). All three fields + * are optional and set together (never a union) since every gasless prompt flow passes all of + * them through from `promptWalletSelection()` regardless of which one is actually populated. + */ +export type GaslessWalletOption = { + encryptedWalletPath?: string; + key?: string; + keyFile?: string; +}; + +export type BaseGaslessTitleEscrowCommand = GaslessWalletOption & { + network: string; + tokenRegistryAddress: string; + tokenId: string; + /** PlatformPaymaster contract address sponsoring gas; always prompted, never read from env. */ + paymasterAddress: string; + remark?: string; + encryptionKey?: string; +}; + +/** Reject commands only cancel a pending transfer; no address inputs beyond the base fields. */ +export type TitleEscrowRejectTransferGaslessCommand = BaseGaslessTitleEscrowCommand; + +export type TitleEscrowTransferHolderGaslessCommand = BaseGaslessTitleEscrowCommand & { + newHolder: string; +}; + +export type TitleEscrowTransferBeneficiaryGaslessCommand = BaseGaslessTitleEscrowCommand & { + newBeneficiary: string; +}; + +export type TitleEscrowNominateBeneficiaryGaslessCommand = BaseGaslessTitleEscrowCommand & { + newBeneficiary: string; +}; + +export type TitleEscrowTransferOwnersGaslessCommand = BaseGaslessTitleEscrowCommand & { + newOwner: string; + newHolder: string; +}; + +/** Returning a document to the issuer is a title-escrow action; same shape as the reject commands. */ +export type TitleEscrowReturnDocumentGaslessCommand = BaseGaslessTitleEscrowCommand; + +/** + * Accepting/rejecting a returned document acts directly on the token registry (the registry admin + * burns or restores the token) rather than on a title escrow, but the input shape is identical. + */ +export type TokenRegistryReturnedDocumentGaslessCommand = BaseGaslessTitleEscrowCommand; + +/** Minting creates a new title escrow, so there is no existing one to resolve beforehand. */ +export type TokenRegistryMintGaslessCommand = GaslessWalletOption & { + network: string; + tokenRegistryAddress: string; + tokenId: string; + beneficiary: string; + holder: string; + /** PlatformPaymaster contract address sponsoring gas; always prompted, never read from env. */ + paymasterAddress: string; + remark?: string; + encryptionKey?: string; +}; diff --git a/src/commands/title-escrow/accept-return-to-issuer.ts b/src/commands/title-escrow/accept-return-to-issuer.ts index 560f5cf..b4bc9b8 100644 --- a/src/commands/title-escrow/accept-return-to-issuer.ts +++ b/src/commands/title-escrow/accept-return-to-issuer.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, success, info } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -21,13 +22,30 @@ import { verifyDocumentSignature, } from '../../utils'; import { connectToTokenRegistry, validateAndEncryptRemark } from '../helpers'; +import { + promptForGaslessAcceptReturnToIssuerInputs, + runAcceptReturnToIssuerGasless, +} from '../gasless'; export const command = 'accept-return-to-issuer'; export const describe = 'Accepts a returned transferable record on the blockchain'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this acceptance as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessAcceptReturnToIssuerInputs(); + return await runAcceptReturnToIssuerGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/endorse-transfer-owner.ts b/src/commands/title-escrow/endorse-transfer-owner.ts index b17c589..a092832 100644 --- a/src/commands/title-escrow/endorse-transfer-owner.ts +++ b/src/commands/title-escrow/endorse-transfer-owner.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -26,14 +27,31 @@ import { validateAndEncryptRemark, validateNominateBeneficiary, } from '../helpers'; +import { + promptForGaslessTransferBeneficiaryInputs, + runTransferBeneficiaryGasless, +} from '../gasless'; export const command = 'endorse-transfer-owner'; export const describe = 'Endorses the transfer of owner of transferable record to an approved owner and approved holder address'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this transfer as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessTransferBeneficiaryInputs(); + return await runTransferBeneficiaryGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/nominate-transfer-owner.ts b/src/commands/title-escrow/nominate-transfer-owner.ts index bcaefa4..7d5fd3d 100644 --- a/src/commands/title-escrow/nominate-transfer-owner.ts +++ b/src/commands/title-escrow/nominate-transfer-owner.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -26,13 +27,27 @@ import { validateAndEncryptRemark, validateNominateBeneficiary, } from '../helpers'; +import { promptForGaslessNominateInputs, runNominateGasless } from '../gasless'; export const command = 'nominate-transfer-owner'; export const describe = 'Nominates the transfer of owner of transferable record to another address'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this nomination as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessNominateInputs(); + return await runNominateGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/reject-return-to-issuer.ts b/src/commands/title-escrow/reject-return-to-issuer.ts index 8e076cf..90a642b 100644 --- a/src/commands/title-escrow/reject-return-to-issuer.ts +++ b/src/commands/title-escrow/reject-return-to-issuer.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, success, info } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -21,13 +22,30 @@ import { verifyDocumentSignature, } from '../../utils'; import { connectToTokenRegistry, validateAndEncryptRemark } from '../helpers'; +import { + promptForGaslessRejectReturnToIssuerInputs, + runRejectReturnToIssuerGasless, +} from '../gasless'; export const command = 'reject-return-to-issuer'; export const describe = 'Rejects a returned transferable record on the blockchain'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this rejection as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessRejectReturnToIssuerInputs(); + return await runRejectReturnToIssuerGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/reject-transfer-holder.ts b/src/commands/title-escrow/reject-transfer-holder.ts index 1bcb7a2..178ff18 100644 --- a/src/commands/title-escrow/reject-transfer-holder.ts +++ b/src/commands/title-escrow/reject-transfer-holder.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -21,13 +22,30 @@ import { verifyDocumentSignature, } from '../../utils'; import { connectToTitleEscrow, validatePreviousHolder, validateAndEncryptRemark } from '../helpers'; +import { + promptForGaslessRejectTransferHolderInputs, + runRejectTransferHolderGasless, +} from '../gasless'; export const command = 'reject-transfer-holder'; export const describe = 'Reject the transfer of the holder of a transferable record'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this rejection as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessRejectTransferHolderInputs(); + return await runRejectTransferHolderGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/reject-transfer-owner-holder.ts b/src/commands/title-escrow/reject-transfer-owner-holder.ts index 0309a1d..c750a14 100644 --- a/src/commands/title-escrow/reject-transfer-owner-holder.ts +++ b/src/commands/title-escrow/reject-transfer-owner-holder.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -26,13 +27,30 @@ import { validatePreviousHolder, validateAndEncryptRemark, } from '../helpers'; +import { + promptForGaslessRejectTransferOwnerHolderInputs, + runRejectTransferOwnerHolderGasless, +} from '../gasless'; export const command = 'reject-transfer-owner-holder'; export const describe = 'Reject the transfer of the owner and holder of a transferable record'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this rejection as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessRejectTransferOwnerHolderInputs(); + return await runRejectTransferOwnerHolderGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/reject-transfer-owner.ts b/src/commands/title-escrow/reject-transfer-owner.ts index f0a9c64..249969b 100644 --- a/src/commands/title-escrow/reject-transfer-owner.ts +++ b/src/commands/title-escrow/reject-transfer-owner.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -28,13 +29,30 @@ import { validateAndEncryptRemark, validatePreviousBeneficiary, } from '../helpers'; +import { + promptForGaslessRejectTransferOwnerInputs, + runRejectTransferOwnerGasless, +} from '../gasless'; export const command = 'reject-transfer-owner'; export const describe = 'Reject the transfer of the owner of a transferable record'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this rejection as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessRejectTransferOwnerInputs(); + return await runRejectTransferOwnerGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/return-to-issuer.ts b/src/commands/title-escrow/return-to-issuer.ts index 0f8fffc..0c34e32 100644 --- a/src/commands/title-escrow/return-to-issuer.ts +++ b/src/commands/title-escrow/return-to-issuer.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, success, info } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -21,13 +22,27 @@ import { verifyDocumentSignature, } from '../../utils'; import { connectToTitleEscrow, validateAndEncryptRemark } from '../helpers'; +import { promptForGaslessReturnToIssuerInputs, runReturnToIssuerGasless } from '../gasless'; export const command = 'return-to-issuer'; export const describe = 'Returns a document on the blockchain'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this return as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessReturnToIssuerInputs(); + return await runReturnToIssuerGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/transfer-holder.ts b/src/commands/title-escrow/transfer-holder.ts index 593168d..3fe5bf8 100644 --- a/src/commands/title-escrow/transfer-holder.ts +++ b/src/commands/title-escrow/transfer-holder.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -22,13 +23,27 @@ import { verifyDocumentSignature, } from '../../utils'; import { connectToTitleEscrow, validateAndEncryptRemark } from '../helpers'; +import { promptForGaslessTransferHolderInputs, runTransferHolderGasless } from '../gasless'; export const command = 'transfer-holder'; export const describe = 'Transfers the holder of the transferable record to another address'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this transfer as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessTransferHolderInputs(); + return await runTransferHolderGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/title-escrow/transfer-owner-holder.ts b/src/commands/title-escrow/transfer-owner-holder.ts index 6d8cc7f..e3283a4 100644 --- a/src/commands/title-escrow/transfer-owner-holder.ts +++ b/src/commands/title-escrow/transfer-owner-holder.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success, warn } from 'signale'; import signale from 'signale'; import { TransactionReceipt } from 'ethers'; @@ -26,14 +27,28 @@ import { validateAndEncryptRemark, validateEndorseChangeOwner, } from '../helpers'; +import { promptForGaslessTransferOwnersInputs, runTransferOwnersGasless } from '../gasless'; export const command = 'transfer-owner-holder'; export const describe = 'Endorses the change of ownership and holdership of transferable record to another address'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this transfer as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessTransferOwnersInputs(); + return await runTransferOwnersGasless(gaslessAnswers); + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/token-registry/deploy.ts b/src/commands/token-registry/deploy.ts index 1cef3ff..2bf973f 100644 --- a/src/commands/token-registry/deploy.ts +++ b/src/commands/token-registry/deploy.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import { error, info, success } from 'signale'; import { CHAIN_ID, deployTokenRegistry, v5Utils, v5ContractAddress } from '@trustvc/trustvc'; import { input, confirm } from '@inquirer/prompts'; @@ -20,13 +21,31 @@ import { waitForTransaction, } from '../helpers'; import { TransactionReceipt, Provider } from 'ethers'; +import { + promptForDeployTokenRegistryGaslessInputs, + runDeployTokenRegistryGasless, +} from '../gasless'; export const command = 'deploy'; export const describe = 'Deploys a document store contract on the blockchain'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Deploy this token registry as a gasless (EIP-7702 + Pimlico sponsored) transaction via a PlatformPaymaster instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address. The caller must already be whitelisted (deployment credits) on the paymaster.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForDeployTokenRegistryGaslessInputs(); + await runDeployTokenRegistryGasless(gaslessAnswers); + return; + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/src/commands/token-registry/mint.ts b/src/commands/token-registry/mint.ts index 348705e..5d71dcf 100644 --- a/src/commands/token-registry/mint.ts +++ b/src/commands/token-registry/mint.ts @@ -1,3 +1,4 @@ +import { Argv } from 'yargs'; import signale, { error, info, success } from 'signale'; import { TokenRegistryMintCommand } from '../../types'; import { @@ -20,13 +21,28 @@ import { import { connectToTokenRegistry, validateAndEncryptRemark } from '../helpers'; import { TransactionReceipt } from 'ethers'; import { mint } from '@trustvc/trustvc'; +import { promptForGaslessMintInputs, runMintGasless } from '../gasless'; export const command = 'mint'; export const describe = 'Mint a hash to a token registry deployed on the blockchain'; -export const handler = async (): Promise => { +export const builder = (yargs: Argv): Argv => + yargs.option('gasless', { + type: 'boolean', + default: false, + description: + 'Perform this mint as a gasless (EIP-7702 + Pimlico sponsored) transaction instead of paying gas directly. Requires PIMLICO_API_KEY and an EIP7702 implementation address to be set in the environment; you will be prompted for the PlatformPaymaster address. The caller must be the paymaster owner and the registry must be authorized on it.', + }); + +export const handler = async (argv: { gasless?: boolean } = {}): Promise => { try { + if (argv.gasless) { + const gaslessAnswers = await promptForGaslessMintInputs(); + await runMintGasless(gaslessAnswers); + return; + } + const answers = await promptForInputs(); if (!answers) return; diff --git a/tests/commands/gasless/admin/add-authorized-caller.test.ts b/tests/commands/gasless/admin/add-authorized-caller.test.ts new file mode 100644 index 0000000..bb40cd4 --- /dev/null +++ b/tests/commands/gasless/admin/add-authorized-caller.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/add-authorized-caller'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + addAuthorizedCaller: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +describe('gasless/admin/add-authorized-caller', () => { + const base = { + network: 'sepolia', + paymasterAddress: '0xpaymaster000000000000000000000000000000', + encryptedWalletPath: './wallet.json', + }; + const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should authorize the prompted caller against the prompted paymaster', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const utils = await import('../../../../src/utils'); + const callerAddress = '0x1234567890123456789012345678901234567890'; + (utils.promptAddress as MockedFunction).mockResolvedValue(callerAddress); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.addAuthorizedCaller as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await handler(); + + expect(utils.promptAddress).toHaveBeenCalledWith( + 'caller', + 'address to authorize on the paymaster', + ); + expect(trustvc.addAuthorizedCaller).toHaveBeenCalledWith( + mockWallet, + base.paymasterAddress, + callerAddress, + ); + expect(common.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...base, + actionLabel: `Authorizing caller ${callerAddress}`, + execute: expect.any(Function), + }), + ); + expect(result).toBe('0xtxhash'); + }); + + it('should log an error and return undefined when prompting fails', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('network not supported'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('network not supported'); + }); + + it('should log a non-Error rejection message', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/tests/commands/gasless/admin/add-registry.test.ts b/tests/commands/gasless/admin/add-registry.test.ts new file mode 100644 index 0000000..60578e0 --- /dev/null +++ b/tests/commands/gasless/admin/add-registry.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/add-registry'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + addRegistry: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +describe('gasless/admin/add-registry', () => { + const base = { + network: 'sepolia', + paymasterAddress: '0xpaymaster000000000000000000000000000000', + encryptedWalletPath: './wallet.json', + }; + const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should authorize the prompted registry against the prompted paymaster', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const utils = await import('../../../../src/utils'); + const registryAddress = '0x1234567890123456789012345678901234567890'; + (utils.promptAddress as MockedFunction).mockResolvedValue(registryAddress); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.addRegistry as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await handler(); + + expect(utils.promptAddress).toHaveBeenCalledWith( + 'registry', + 'token registry address to authorize', + ); + expect(trustvc.addRegistry).toHaveBeenCalledWith( + mockWallet, + base.paymasterAddress, + registryAddress, + ); + expect(common.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...base, + actionLabel: `Authorizing registry ${registryAddress}`, + execute: expect.any(Function), + }), + ); + expect(result).toBe('0xtxhash'); + }); + + it('should log an error and return undefined when prompting fails', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('network not supported'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('network not supported'); + }); + + it('should log a non-Error rejection message', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/tests/commands/gasless/admin/add-title-escrow.test.ts b/tests/commands/gasless/admin/add-title-escrow.test.ts new file mode 100644 index 0000000..a2e2984 --- /dev/null +++ b/tests/commands/gasless/admin/add-title-escrow.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/add-title-escrow'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + addTitleEscrow: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +describe('gasless/admin/add-title-escrow', () => { + const base = { + network: 'sepolia', + paymasterAddress: '0xpaymaster000000000000000000000000000000', + encryptedWalletPath: './wallet.json', + }; + const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should authorize the prompted title escrow against the prompted paymaster', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const utils = await import('../../../../src/utils'); + const titleEscrowAddress = '0x1234567890123456789012345678901234567890'; + (utils.promptAddress as MockedFunction).mockResolvedValue(titleEscrowAddress); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.addTitleEscrow as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await handler(); + + expect(utils.promptAddress).toHaveBeenCalledWith( + 'title escrow', + 'title escrow address to authorize', + ); + expect(trustvc.addTitleEscrow).toHaveBeenCalledWith( + mockWallet, + base.paymasterAddress, + titleEscrowAddress, + ); + expect(common.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...base, + actionLabel: `Authorizing title escrow ${titleEscrowAddress}`, + execute: expect.any(Function), + }), + ); + expect(result).toBe('0xtxhash'); + }); + + it('should log an error and return undefined when prompting fails', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('network not supported'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('network not supported'); + }); + + it('should log a non-Error rejection message', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/tests/commands/gasless/admin/common.test.ts b/tests/commands/gasless/admin/common.test.ts new file mode 100644 index 0000000..a7d4564 --- /dev/null +++ b/tests/commands/gasless/admin/common.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForPaymasterAdminWalletInputs, + runPaymasterAdminAction, +} from '../../../../src/commands/gasless/admin/common'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('../../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + promptNetworkSelection: vi.fn(), + promptWalletSelection: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://sepolia.etherscan.io'), + }; +}); + +describe('gasless/admin/common', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForPaymasterAdminWalletInputs', () => { + it('should resolve with the prompted wallet inputs for a supported network', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('sepolia'); + (utils.promptAddress as MockedFunction).mockResolvedValue( + '0x1111111111111111111111111111111111111111', + ); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const result = await promptForPaymasterAdminWalletInputs(); + + expect(result).toEqual({ + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', + key: undefined, + keyFile: undefined, + }); + }); + + it('should resolve with an amoy network', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('amoy'); + (utils.promptAddress as MockedFunction).mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + key: '0xprivatekey', + }); + + const result = await promptForPaymasterAdminWalletInputs(); + + expect(result.network).toBe('amoy'); + expect(result.key).toBe('0xprivatekey'); + }); + + it('should reject when the network is not gasless-supported', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('mainnet'); + + await expect(promptForPaymasterAdminWalletInputs()).rejects.toThrow( + /Gasless transactions are only supported on/, + ); + + expect(utils.promptAddress).not.toHaveBeenCalled(); + expect(utils.promptWalletSelection).not.toHaveBeenCalled(); + }); + }); + + describe('runPaymasterAdminAction', () => { + let getWalletOrSignerMock: MockedFunction; + let mockWallet: any; + + beforeEach(async () => { + const walletModule = await import('../../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + getWalletOrSignerMock.mockResolvedValue(mockWallet); + + const utils = await import('../../../../src/utils'); + (utils.getErrorMessage as MockedFunction).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + (utils.getEtherscanAddress as MockedFunction).mockReturnValue( + 'https://sepolia.etherscan.io', + ); + }); + + it('should resolve with the tx hash and report success/info on a successful execute', async () => { + const execute = vi.fn().mockResolvedValue('0xtxhash'); + + const result = await runPaymasterAdminAction({ + network: 'sepolia', + paymasterAddress: '0xpaymaster', + actionLabel: 'Doing thing', + execute, + }); + + expect(result).toBe('0xtxhash'); + expect(execute).toHaveBeenCalledWith(mockWallet); + + const signale = await import('signale'); + expect(signale.success).toHaveBeenCalledWith('Doing thing — done'); + expect(signale.info).toHaveBeenCalled(); + }); + + it('should catch an Error thrown by execute, log it, and return undefined', async () => { + const execute = vi.fn().mockRejectedValue(new Error('execute blew up')); + + const result = await runPaymasterAdminAction({ + network: 'sepolia', + paymasterAddress: '0xpaymaster', + actionLabel: 'Doing thing', + execute, + }); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('execute blew up'); + }); + + it('should catch a non-Error rejection from execute, log it, and return undefined', async () => { + const execute = vi.fn().mockRejectedValue('string failure'); + + const result = await runPaymasterAdminAction({ + network: 'sepolia', + paymasterAddress: '0xpaymaster', + actionLabel: 'Doing thing', + execute, + }); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('string failure'); + }); + + it('should not call execute and should log an error for an unsupported network', async () => { + const execute = vi.fn(); + + const result = await runPaymasterAdminAction({ + network: 'mainnet', + paymasterAddress: '0xpaymaster', + actionLabel: 'Doing thing', + execute, + }); + + expect(result).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith( + expect.stringMatching(/Gasless transactions are only supported on/), + ); + }); + }); +}); diff --git a/tests/commands/gasless/admin/delegate-user.test.ts b/tests/commands/gasless/admin/delegate-user.test.ts new file mode 100644 index 0000000..bbea46e --- /dev/null +++ b/tests/commands/gasless/admin/delegate-user.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/delegate-user'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +const { signAuthorizationMock, sendTransactionMock, createWalletClientMock } = vi.hoisted(() => { + const signAuthorizationMock = vi.fn(); + const sendTransactionMock = vi.fn(); + const createWalletClientMock = vi.fn().mockImplementation(() => ({ + signAuthorization: signAuthorizationMock, + sendTransaction: sendTransactionMock, + })); + return { signAuthorizationMock, sendTransactionMock, createWalletClientMock }; +}); + +vi.mock('viem', async () => { + const actual = await vi.importActual('viem'); + return { + ...actual, + createWalletClient: createWalletClientMock, + http: vi.fn(() => 'http-transport'), + }; +}); + +vi.mock('viem/accounts', async () => { + const actual = await vi.importActual('viem/accounts'); + return { + ...actual, + privateKeyToAccount: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptNetworkSelection: vi.fn(), + promptWalletSelection: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://sepolia.etherscan.io'), + }; +}); + +vi.mock('../../../../src/commands/gasless/config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getEip7702ImplementationAddress: vi.fn(), + getGaslessRpcUrl: vi.fn(), + getViemChain: vi.fn(), + }; +}); + +describe('gasless/admin/delegate-user', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetAllMocks(); + + createWalletClientMock.mockImplementation(() => ({ + signAuthorization: signAuthorizationMock, + sendTransaction: sendTransactionMock, + })); + + const viemModule = await import('viem'); + (viemModule.http as MockedFunction).mockReturnValue('http-transport'); + + const utils = await import('../../../../src/utils'); + (utils.getErrorMessage as MockedFunction).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + (utils.getEtherscanAddress as MockedFunction).mockReturnValue( + 'https://sepolia.etherscan.io', + ); + + const config = await import('../../../../src/commands/gasless/config'); + (config.getEip7702ImplementationAddress as MockedFunction).mockReturnValue( + '0xImplementationAddress00000000000000000000', + ); + (config.getGaslessRpcUrl as MockedFunction).mockReturnValue('https://rpc.example'); + (config.getViemChain as MockedFunction).mockReturnValue({ id: 11155111 }); + }); + + it('should delegate the user account via a standalone EIP-7702 authorization', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('sepolia'); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const walletModule = await import('../../../../src/utils/wallet'); + (walletModule.getWalletOrSigner as MockedFunction).mockResolvedValue({ + privateKey: '0xprivatekey00000000000000000000000000000000000000000000000000', + }); + + const accountsModule = await import('viem/accounts'); + const account = { address: '0xAccountAddress0000000000000000000000000' }; + (accountsModule.privateKeyToAccount as MockedFunction).mockReturnValue(account); + + signAuthorizationMock.mockResolvedValue({ authorization: 'signed' }); + sendTransactionMock.mockResolvedValue('0xtxhash'); + + const result = await handler(); + + expect(createWalletClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + account, + chain: { id: 11155111 }, + transport: 'http-transport', + }), + ); + expect(signAuthorizationMock).toHaveBeenCalledWith({ + contractAddress: '0xImplementationAddress00000000000000000000', + executor: 'self', + }); + expect(sendTransactionMock).toHaveBeenCalledWith({ + authorizationList: [{ authorization: 'signed' }], + to: account.address, + value: 0n, + }); + expect(result).toBe('0xtxhash'); + + const signale = await import('signale'); + expect(signale.success).toHaveBeenCalled(); + }); + + it('should log an error and return undefined when the wallet has no private key', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('sepolia'); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + + const walletModule = await import('../../../../src/utils/wallet'); + (walletModule.getWalletOrSigner as MockedFunction).mockResolvedValue({}); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith( + expect.stringContaining('Delegating requires direct access to a private key'), + ); + expect(createWalletClientMock).not.toHaveBeenCalled(); + }); + + it('should log an error and return undefined for an unsupported network', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockResolvedValue('mainnet'); + + const result = await handler(); + + expect(result).toBeUndefined(); + expect(utils.promptWalletSelection).not.toHaveBeenCalled(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith( + expect.stringMatching(/Gasless transactions are only supported on/), + ); + }); + + it('should log a non-Error rejection message', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/tests/commands/gasless/admin/fund-paymaster.test.ts b/tests/commands/gasless/admin/fund-paymaster.test.ts new file mode 100644 index 0000000..6b762f7 --- /dev/null +++ b/tests/commands/gasless/admin/fund-paymaster.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/fund-paymaster'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +const { contractDepositMock, ContractMock } = vi.hoisted(() => { + const contractDepositMock = vi.fn(); + const ContractMock = vi.fn().mockImplementation(() => ({ + deposit: contractDepositMock, + })); + return { contractDepositMock, ContractMock }; +}); + +vi.mock('ethers', async () => { + const actual = await vi.importActual('ethers'); + return { + ...actual, + ethers: { + ...actual.ethers, + Contract: ContractMock, + parseEther: vi.fn((value: string) => `wei(${value})`), + }, + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +describe('gasless/admin/fund-paymaster', () => { + const base = { + network: 'sepolia', + paymasterAddress: '0xpaymaster000000000000000000000000000000', + encryptedWalletPath: './wallet.json', + }; + const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetAllMocks(); + ContractMock.mockImplementation(() => ({ + deposit: contractDepositMock, + })); + + const { ethers } = await import('ethers'); + (ethers.parseEther as MockedFunction).mockImplementation( + (value: string) => `wei(${value})`, + ); + }); + + it('should deposit the entered ETH amount into the paymaster contract', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const { input } = await import('@inquirer/prompts'); + (input as MockedFunction).mockResolvedValue('1.5'); + + const receipt = { hash: '0xtxhash' }; + contractDepositMock.mockResolvedValue({ wait: vi.fn().mockResolvedValue(receipt) }); + + const result = await handler(); + + expect(ContractMock).toHaveBeenCalledWith(base.paymasterAddress, expect.anything(), mockWallet); + expect(contractDepositMock).toHaveBeenCalledWith({ value: 'wei(1.5)' }); + expect(common.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...base, + actionLabel: 'Depositing 1.5 ETH', + execute: expect.any(Function), + }), + ); + expect(result).toBe('0xtxhash'); + }); + + it('should log an error and return undefined when prompting fails', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('network not supported'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('network not supported'); + }); + + it('should log a non-Error rejection message', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); + + describe('amount validation', () => { + let validate: (value: string) => string | boolean; + + beforeEach(async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockResolvedValue('0xtxhash'); + + const { input } = await import('@inquirer/prompts'); + (input as MockedFunction).mockImplementation(async (options: any) => { + validate = options.validate; + return '1'; + }); + + await handler(); + }); + + it('should reject a non-numeric value', () => { + expect(validate('abc')).toBe('Amount must be a positive number (ETH)'); + }); + + it('should reject zero', () => { + expect(validate('0')).toBe('Amount must be a positive number (ETH)'); + }); + + it('should reject a negative value', () => { + expect(validate('-1')).toBe('Amount must be a positive number (ETH)'); + }); + + it('should accept a positive integer', () => { + expect(validate('1')).toBe(true); + }); + + it('should accept a positive decimal', () => { + expect(validate('0.5')).toBe(true); + }); + }); +}); diff --git a/tests/commands/gasless/admin/remove-authorized-caller.test.ts b/tests/commands/gasless/admin/remove-authorized-caller.test.ts new file mode 100644 index 0000000..100ad97 --- /dev/null +++ b/tests/commands/gasless/admin/remove-authorized-caller.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/remove-authorized-caller'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + removeAuthorizedCaller: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +describe('gasless/admin/remove-authorized-caller', () => { + const base = { + network: 'sepolia', + paymasterAddress: '0xpaymaster000000000000000000000000000000', + encryptedWalletPath: './wallet.json', + }; + const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('should remove the prompted caller from the prompted paymaster', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue(base); + (common.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const utils = await import('../../../../src/utils'); + const callerAddress = '0x1234567890123456789012345678901234567890'; + (utils.promptAddress as MockedFunction).mockResolvedValue(callerAddress); + + const trustvc = await import('@trustvc/trustvc'); + (trustvc.removeAuthorizedCaller as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await handler(); + + expect(utils.promptAddress).toHaveBeenCalledWith( + 'caller', + 'address to remove from the paymaster', + ); + expect(trustvc.removeAuthorizedCaller).toHaveBeenCalledWith( + mockWallet, + base.paymasterAddress, + callerAddress, + ); + expect(common.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...base, + actionLabel: `Removing caller ${callerAddress}`, + execute: expect.any(Function), + }), + ); + expect(result).toBe('0xtxhash'); + }); + + it('should log an error and return undefined when prompting fails', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('network not supported'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('network not supported'); + }); + + it('should log a non-Error rejection message', async () => { + const common = await import('../../../../src/commands/gasless/admin/common'); + (common.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue('boom'); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signale = await import('signale'); + expect(signale.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/tests/commands/gasless/admin/remove-registry.test.ts b/tests/commands/gasless/admin/remove-registry.test.ts new file mode 100644 index 0000000..62d88b8 --- /dev/null +++ b/tests/commands/gasless/admin/remove-registry.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/remove-registry'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + removeRegistry: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/remove-registry', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('removes the registry with the prompted address and returns the tx hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const utilsModule = await import('../../../../src/utils'); + const trustvcModule = await import('@trustvc/trustvc'); + + const registryAddress = '0x2222222222222222222222222222222222222222'; + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (utilsModule.promptAddress as MockedFunction).mockResolvedValue(registryAddress); + (trustvcModule.removeRegistry as MockedFunction).mockResolvedValue('0xtxhash'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xtxhash'); + expect(utilsModule.promptAddress).toHaveBeenCalledWith( + 'registry', + 'token registry address to deauthorize', + ); + expect(trustvcModule.removeRegistry).toHaveBeenCalledWith( + mockWallet, + baseInputs.paymasterAddress, + registryAddress, + ); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: `Deauthorizing registry ${registryAddress}`, + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); +}); diff --git a/tests/commands/gasless/admin/remove-title-escrow.test.ts b/tests/commands/gasless/admin/remove-title-escrow.test.ts new file mode 100644 index 0000000..56935cb --- /dev/null +++ b/tests/commands/gasless/admin/remove-title-escrow.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/remove-title-escrow'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + removeTitleEscrow: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/remove-title-escrow', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('removes the title escrow with the prompted address and returns the tx hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const utilsModule = await import('../../../../src/utils'); + const trustvcModule = await import('@trustvc/trustvc'); + + const titleEscrowAddress = '0x3333333333333333333333333333333333333333'; + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (utilsModule.promptAddress as MockedFunction).mockResolvedValue(titleEscrowAddress); + (trustvcModule.removeTitleEscrow as MockedFunction).mockResolvedValue('0xtxhash'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xtxhash'); + expect(utilsModule.promptAddress).toHaveBeenCalledWith( + 'title escrow', + 'title escrow address to deauthorize', + ); + expect(trustvcModule.removeTitleEscrow).toHaveBeenCalledWith( + mockWallet, + baseInputs.paymasterAddress, + titleEscrowAddress, + ); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: `Deauthorizing title escrow ${titleEscrowAddress}`, + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); +}); diff --git a/tests/commands/gasless/admin/remove-user-from-whitelist.test.ts b/tests/commands/gasless/admin/remove-user-from-whitelist.test.ts new file mode 100644 index 0000000..609c3ad --- /dev/null +++ b/tests/commands/gasless/admin/remove-user-from-whitelist.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/remove-user-from-whitelist'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + removeUserFromWhitelist: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/remove-user-from-whitelist', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('removes the user from the whitelist with the prompted address and returns the tx hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const utilsModule = await import('../../../../src/utils'); + const trustvcModule = await import('@trustvc/trustvc'); + + const userAddress = '0x4444444444444444444444444444444444444444'; + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (utilsModule.promptAddress as MockedFunction).mockResolvedValue(userAddress); + (trustvcModule.removeUserFromWhitelist as MockedFunction).mockResolvedValue('0xtxhash'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xtxhash'); + expect(utilsModule.promptAddress).toHaveBeenCalledWith( + 'user', + 'address to remove from the whitelist', + ); + expect(trustvcModule.removeUserFromWhitelist).toHaveBeenCalledWith( + mockWallet, + baseInputs.paymasterAddress, + userAddress, + ); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: `Removing user ${userAddress} from whitelist`, + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); +}); diff --git a/tests/commands/gasless/admin/set-daily-limit.test.ts b/tests/commands/gasless/admin/set-daily-limit.test.ts new file mode 100644 index 0000000..8a504eb --- /dev/null +++ b/tests/commands/gasless/admin/set-daily-limit.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/set-daily-limit'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + setDailyLimit: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/set-daily-limit', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('sets the daily limit with the prompted value and returns the tx hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const inquirerModule = await import('@inquirer/prompts'); + const trustvcModule = await import('@trustvc/trustvc'); + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (inquirerModule.input as MockedFunction).mockResolvedValue('5000'); + (trustvcModule.setDailyLimit as MockedFunction).mockResolvedValue('0xtxhash'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xtxhash'); + expect(trustvcModule.setDailyLimit).toHaveBeenCalledWith( + mockWallet, + baseInputs.paymasterAddress, + BigInt('5000'), + ); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: 'Setting daily limit to 5000 wei', + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); + + describe('daily limit validate()', () => { + it('captures the validate callback passed to input()', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const inquirerModule = await import('@inquirer/prompts'); + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (inquirerModule.input as MockedFunction).mockResolvedValue('0'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockResolvedValue('0xtxhash'); + + await handler(); + + const call = (inquirerModule.input as MockedFunction).mock.calls[0][0]; + const validate = call.validate as (value: string) => string | true; + + expect(validate('0')).toBe(true); + expect(validate('12345')).toBe(true); + expect(validate('-1')).not.toBe(true); + expect(validate('1.5')).not.toBe(true); + expect(validate('abc')).not.toBe(true); + expect(validate('')).not.toBe(true); + }); + }); +}); diff --git a/tests/commands/gasless/admin/set-user-whitelist.test.ts b/tests/commands/gasless/admin/set-user-whitelist.test.ts new file mode 100644 index 0000000..3fc027d --- /dev/null +++ b/tests/commands/gasless/admin/set-user-whitelist.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { handler } from '../../../../src/commands/gasless/admin/set-user-whitelist'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + setUserWhitelist: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + promptAddress: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/set-user-whitelist', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('whitelists the user with the prompted address and credits, returning the tx hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const utilsModule = await import('../../../../src/utils'); + const inquirerModule = await import('@inquirer/prompts'); + const trustvcModule = await import('@trustvc/trustvc'); + + const userAddress = '0x5555555555555555555555555555555555555555'; + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (utilsModule.promptAddress as MockedFunction).mockResolvedValue(userAddress); + (inquirerModule.input as MockedFunction).mockResolvedValue('2'); + (trustvcModule.setUserWhitelist as MockedFunction).mockResolvedValue('0xtxhash'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xtxhash'); + expect(utilsModule.promptAddress).toHaveBeenCalledWith( + 'user', + 'address to whitelist for gasless registry deployment', + ); + expect(trustvcModule.setUserWhitelist).toHaveBeenCalledWith( + mockWallet, + baseInputs.paymasterAddress, + userAddress, + BigInt('2'), + ); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: `Whitelisting user ${userAddress} with 2 credit(s)`, + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); + + describe('credits validate()', () => { + it('only accepts integers between 0 and 3', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const utilsModule = await import('../../../../src/utils'); + const inquirerModule = await import('@inquirer/prompts'); + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (utilsModule.promptAddress as MockedFunction).mockResolvedValue( + '0x5555555555555555555555555555555555555555', + ); + (inquirerModule.input as MockedFunction).mockResolvedValue('1'); + (commonModule.runPaymasterAdminAction as MockedFunction).mockResolvedValue('0xtxhash'); + + await handler(); + + const call = (inquirerModule.input as MockedFunction).mock.calls[0][0]; + const validate = call.validate as (value: string) => string | true; + + expect(validate('0')).toBe(true); + expect(validate('1')).toBe(true); + expect(validate('2')).toBe(true); + expect(validate('3')).toBe(true); + expect(validate('4')).not.toBe(true); + expect(validate('-1')).not.toBe(true); + expect(validate('10')).not.toBe(true); + expect(validate('abc')).not.toBe(true); + expect(validate('')).not.toBe(true); + }); + }); +}); diff --git a/tests/commands/gasless/admin/stake-paymaster.test.ts b/tests/commands/gasless/admin/stake-paymaster.test.ts new file mode 100644 index 0000000..56fabbb --- /dev/null +++ b/tests/commands/gasless/admin/stake-paymaster.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { eip7702Abis } from '@trustvc/trustvc'; +import { ethers } from 'ethers'; +import { handler } from '../../../../src/commands/gasless/admin/stake-paymaster'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('ethers', async () => { + const actual = await vi.importActual('ethers'); + return { + ...actual, + ethers: { + ...actual.ethers, + Contract: vi.fn(), + }, + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +vi.mock('../../../../src/commands/gasless/admin/common', () => ({ + promptForPaymasterAdminWalletInputs: vi.fn(), + runPaymasterAdminAction: vi.fn(), +})); + +const baseInputs = { + network: 'sepolia', + paymasterAddress: '0x1111111111111111111111111111111111111111', + encryptedWalletPath: './wallet.json', +}; + +const mockWallet = { getAddress: vi.fn().mockResolvedValue('0xfrom') }; + +describe('gasless/admin/stake-paymaster', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('handler', () => { + it('stakes the parsed ETH amount with the prompted unstake delay and returns the receipt hash', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const inquirerModule = await import('@inquirer/prompts'); + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (inquirerModule.input as MockedFunction) + .mockResolvedValueOnce('1.5') + .mockResolvedValueOnce('3600'); + + const addStakeMock = vi.fn().mockResolvedValue({ + wait: vi.fn().mockResolvedValue({ hash: '0xreceipthash' }), + }); + (ethers.Contract as unknown as MockedFunction).mockImplementation( + () => + ({ + addStake: addStakeMock, + }) as any, + ); + + (commonModule.runPaymasterAdminAction as MockedFunction).mockImplementation( + async ({ execute }: any) => execute(mockWallet), + ); + + const result = await handler(); + + expect(result).toBe('0xreceipthash'); + expect(ethers.Contract).toHaveBeenCalledWith( + baseInputs.paymasterAddress, + eip7702Abis.platformPaymasterAbi, + mockWallet, + ); + expect(addStakeMock).toHaveBeenCalledWith(3600, { value: ethers.parseEther('1.5') }); + expect(commonModule.runPaymasterAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseInputs, + actionLabel: 'Staking 1.5 ETH with a 3600s unstake delay', + execute: expect.any(Function), + }), + ); + }); + + it('logs an error and returns undefined when prompting fails', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + new Error('wallet selection failed'), + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('wallet selection failed'); + }); + + it('logs a string error when a non-Error is thrown during prompting', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockRejectedValue( + 'raw string failure', + ); + + const result = await handler(); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); + + describe('amount and unstake delay validate()', () => { + it('captures both validate callbacks and rejects invalid input', async () => { + const commonModule = await import('../../../../src/commands/gasless/admin/common'); + const inquirerModule = await import('@inquirer/prompts'); + + (commonModule.promptForPaymasterAdminWalletInputs as MockedFunction).mockResolvedValue( + baseInputs, + ); + (inquirerModule.input as MockedFunction) + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('86400'); + (ethers.Contract as unknown as MockedFunction).mockImplementation( + () => + ({ + addStake: vi + .fn() + .mockResolvedValue({ wait: vi.fn().mockResolvedValue({ hash: '0x1' }) }), + }) as any, + ); + (commonModule.runPaymasterAdminAction as MockedFunction).mockResolvedValue('0xtxhash'); + + await handler(); + + const amountCall = (inquirerModule.input as MockedFunction).mock.calls[0][0]; + const delayCall = (inquirerModule.input as MockedFunction).mock.calls[1][0]; + + expect(delayCall.default).toBe('86400'); + + const validateAmount = amountCall.validate as (value: string) => string | true; + expect(validateAmount('1')).toBe(true); + expect(validateAmount('0.5')).toBe(true); + expect(validateAmount('0')).not.toBe(true); + expect(validateAmount('-1')).not.toBe(true); + expect(validateAmount('abc')).not.toBe(true); + expect(validateAmount('')).not.toBe(true); + + const validateDelay = delayCall.validate as (value: string) => string | true; + expect(validateDelay('86400')).toBe(true); + expect(validateDelay('1')).toBe(true); + expect(validateDelay('0')).not.toBe(true); + expect(validateDelay('-100')).not.toBe(true); + expect(validateDelay('1.5')).not.toBe(true); + expect(validateDelay('abc')).not.toBe(true); + }); + }); +}); diff --git a/tests/commands/gasless/client.test.ts b/tests/commands/gasless/client.test.ts new file mode 100644 index 0000000..29ed8d7 --- /dev/null +++ b/tests/commands/gasless/client.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { buildGaslessSmartAccountClient } from '../../../src/commands/gasless/client'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('../../../src/commands/gasless/config', () => ({ + getGaslessRpcUrl: vi.fn(), + getEip7702ImplementationAddress: vi.fn(), + getPimlicoBundlerUrl: vi.fn(), + getViemChain: vi.fn(), + getPimlicoApiKey: vi.fn(), +})); + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(), + }; +}); + +vi.mock('viem/accounts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + privateKeyToAccount: vi.fn(), + }; +}); + +vi.mock('viem/account-abstraction', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + entryPoint08Address: '0xentrypoint08', + }; +}); + +vi.mock('permissionless/accounts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + to7702SimpleSmartAccount: vi.fn(), + }; +}); + +vi.mock('permissionless', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSmartAccountClient: vi.fn(), + }; +}); + +vi.mock('permissionless/clients/pimlico', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPimlicoClient: vi.fn(), + }; +}); + +const fakeChain = { id: 11155111, name: 'fake-chain' }; +const fakePublicClient = { __brand: 'publicClient' }; +const fakeOwner = { __brand: 'owner' }; +const fakeSmartAccount = { + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', +}; +const fakeSmartAccountClient = { __brand: 'smartAccountClient' }; +const fakePimlicoGasPrice = { + fast: { maxFeePerGas: 100n, maxPriorityFeePerGas: 10n }, +}; + +describe('gasless/client', () => { + let getGaslessRpcUrlMock: MockedFunction; + let getEip7702ImplementationAddressMock: MockedFunction; + let getPimlicoBundlerUrlMock: MockedFunction; + let getViemChainMock: MockedFunction; + let getPimlicoApiKeyMock: MockedFunction; + let createPublicClientMock: MockedFunction; + let privateKeyToAccountMock: MockedFunction; + let to7702SimpleSmartAccountMock: MockedFunction; + let createSmartAccountClientMock: MockedFunction; + let createPimlicoClientMock: MockedFunction; + let getUserOperationGasPriceMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetAllMocks(); + + const configModule = await import('../../../src/commands/gasless/config'); + getGaslessRpcUrlMock = configModule.getGaslessRpcUrl as MockedFunction; + getEip7702ImplementationAddressMock = + configModule.getEip7702ImplementationAddress as MockedFunction; + getPimlicoBundlerUrlMock = configModule.getPimlicoBundlerUrl as MockedFunction; + getViemChainMock = configModule.getViemChain as MockedFunction; + getPimlicoApiKeyMock = configModule.getPimlicoApiKey as MockedFunction; + + const viemModule = await import('viem'); + createPublicClientMock = viemModule.createPublicClient as MockedFunction; + + const viemAccountsModule = await import('viem/accounts'); + privateKeyToAccountMock = viemAccountsModule.privateKeyToAccount as MockedFunction; + + const permissionlessAccountsModule = await import('permissionless/accounts'); + to7702SimpleSmartAccountMock = + permissionlessAccountsModule.to7702SimpleSmartAccount as MockedFunction; + + const permissionlessModule = await import('permissionless'); + createSmartAccountClientMock = + permissionlessModule.createSmartAccountClient as MockedFunction; + + const pimlicoModule = await import('permissionless/clients/pimlico'); + createPimlicoClientMock = pimlicoModule.createPimlicoClient as MockedFunction; + + getGaslessRpcUrlMock.mockReturnValue('https://fake-rpc.example.com'); + getEip7702ImplementationAddressMock.mockReturnValue( + '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead', + ); + getPimlicoBundlerUrlMock.mockReturnValue('https://fake-bundler.example.com'); + getViemChainMock.mockReturnValue(fakeChain); + getPimlicoApiKeyMock.mockReturnValue('fake-api-key'); + + createPublicClientMock.mockReturnValue(fakePublicClient); + privateKeyToAccountMock.mockReturnValue(fakeOwner); + to7702SimpleSmartAccountMock.mockResolvedValue(fakeSmartAccount); + createSmartAccountClientMock.mockReturnValue(fakeSmartAccountClient); + getUserOperationGasPriceMock = vi.fn().mockResolvedValue(fakePimlicoGasPrice); + createPimlicoClientMock.mockReturnValue({ + getUserOperationGasPrice: getUserOperationGasPriceMock, + }); + }); + + it('builds a smart account client wired to the mocked collaborators', async () => { + const result = await buildGaslessSmartAccountClient({ + network: 'sepolia' as any, + privateKey: '0xprivatekey', + paymasterAddress: '0xpaymaster0000000000000000000000000000000', + }); + + expect(to7702SimpleSmartAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ + client: fakePublicClient, + owner: fakeOwner, + accountLogicAddress: '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead', + }), + ); + + expect(createSmartAccountClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + account: fakeSmartAccount, + chain: fakeChain, + }), + ); + + expect(result).toEqual({ + smartAccountClient: fakeSmartAccountClient, + smartAccountAddress: fakeSmartAccount.address, + }); + }); + + it('resolves paymaster stub/data with the given paymaster address and delegates fee estimation to the pimlico client', async () => { + const paymasterAddress = '0xpaymaster0000000000000000000000000000000'; + + await buildGaslessSmartAccountClient({ + network: 'sepolia' as any, + privateKey: '0xprivatekey', + paymasterAddress: paymasterAddress as `0x${string}`, + }); + + expect(createSmartAccountClientMock).toHaveBeenCalledTimes(1); + const callArgs = createSmartAccountClientMock.mock.calls[0][0] as any; + + const stubData = await callArgs.paymaster.getPaymasterStubData(); + expect(stubData).toEqual( + expect.objectContaining({ + paymaster: paymasterAddress, + }), + ); + + const paymasterData = await callArgs.paymaster.getPaymasterData(); + expect(paymasterData).toEqual( + expect.objectContaining({ + paymaster: paymasterAddress, + }), + ); + + const estimatedFees = await callArgs.userOperation.estimateFeesPerGas(); + expect(estimatedFees).toBe(fakePimlicoGasPrice.fast); + expect(getUserOperationGasPriceMock).toHaveBeenCalled(); + }); + + it('passes the bundler url built from the mocked network and api key', async () => { + await buildGaslessSmartAccountClient({ + network: 'amoy' as any, + privateKey: '0xprivatekey', + paymasterAddress: '0xpaymaster0000000000000000000000000000000', + }); + + expect(getPimlicoBundlerUrlMock).toHaveBeenCalledWith('amoy', 'fake-api-key'); + expect(createSmartAccountClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + bundlerTransport: expect.anything(), + }), + ); + }); +}); diff --git a/tests/commands/gasless/common.test.ts b/tests/commands/gasless/common.test.ts new file mode 100644 index 0000000..eace652 --- /dev/null +++ b/tests/commands/gasless/common.test.ts @@ -0,0 +1,348 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + resolveTitleEscrowAddress, + prepareGaslessRun, + prepareGaslessRegistryRun, +} from '../../../src/commands/gasless/common'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getSupportedNetwork: vi.fn(), + }; +}); + +vi.mock('../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), +})); + +vi.mock('../../../src/commands/gasless/client', () => ({ + buildGaslessSmartAccountClient: vi.fn(), +})); + +vi.mock('../../../src/commands/gasless/eligibility', () => ({ + checkGaslessEligibility: vi.fn(), +})); + +vi.mock('ethers', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ethers: { + ...actual.ethers, + Contract: vi.fn(), + }, + }; +}); + +describe('gasless/common', () => { + let getSupportedNetworkMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let assertGaslessSupportedNetworkMock: MockedFunction; + let buildGaslessSmartAccountClientMock: MockedFunction; + let checkGaslessEligibilityMock: MockedFunction; + let ContractMock: MockedFunction; + + const fakeProvider = { __brand: 'provider' }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetAllMocks(); + + const utilsModule = await import('../../../src/utils'); + getSupportedNetworkMock = utilsModule.getSupportedNetwork as MockedFunction; + getSupportedNetworkMock.mockReturnValue({ provider: () => fakeProvider }); + + const walletModule = await import('../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + const configModule = await import('../../../src/commands/gasless/config'); + assertGaslessSupportedNetworkMock = + configModule.assertGaslessSupportedNetwork as MockedFunction; + assertGaslessSupportedNetworkMock.mockImplementation((n: string) => n); + + const clientModule = await import('../../../src/commands/gasless/client'); + buildGaslessSmartAccountClientMock = + clientModule.buildGaslessSmartAccountClient as MockedFunction; + + const eligibilityModule = await import('../../../src/commands/gasless/eligibility'); + checkGaslessEligibilityMock = eligibilityModule.checkGaslessEligibility as MockedFunction; + checkGaslessEligibilityMock.mockResolvedValue(undefined); + + const ethersModule = await import('ethers'); + ContractMock = ethersModule.ethers.Contract as MockedFunction; + }); + + describe('resolveTitleEscrowAddress', () => { + it('returns the resolved title escrow address from ownerOf', async () => { + const escrowAddress = '0xescrow000000000000000000000000000000000'; + ContractMock.mockImplementation(() => ({ + ownerOf: vi.fn().mockResolvedValue(escrowAddress), + })); + + const result = await resolveTitleEscrowAddress({ + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + network: 'sepolia', + }); + + expect(result).toBe(escrowAddress); + }); + + it('throws when ownerOf resolves to the zero address', async () => { + const ethersModule = await import('ethers'); + ContractMock.mockImplementation(() => ({ + ownerOf: vi.fn().mockResolvedValue(ethersModule.ethers.ZeroAddress), + })); + + await expect( + resolveTitleEscrowAddress({ + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + network: 'sepolia', + }), + ).rejects.toThrow(/Unable to resolve the title escrow address/); + }); + + it('throws when ownerOf resolves to a falsy value', async () => { + ContractMock.mockImplementation(() => ({ + ownerOf: vi.fn().mockResolvedValue(''), + })); + + await expect( + resolveTitleEscrowAddress({ + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + network: 'sepolia', + }), + ).rejects.toThrow(/Unable to resolve the title escrow address/); + }); + }); + + describe('prepareGaslessRun', () => { + const titleEscrowAddress = '0xescrow000000000000000000000000000000000'; + const callerAddress = '0xCaLLeR000000000000000000000000000000000'; + const paymasterAddress = '0xpaymaster000000000000000000000000000000'; + + beforeEach(() => { + ContractMock.mockImplementation(() => ({ + ownerOf: vi.fn().mockResolvedValue(titleEscrowAddress), + holder: vi.fn().mockResolvedValue(callerAddress), + beneficiary: vi.fn().mockResolvedValue(callerAddress), + })); + + buildGaslessSmartAccountClientMock.mockResolvedValue({ + smartAccountClient: { __brand: 'smartAccountClient' }, + }); + }); + + it('throws when the resolved wallet has no privateKey', async () => { + getWalletOrSignerMock.mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + await expect( + prepareGaslessRun({ + network: 'sepolia', + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + paymasterAddress, + requiredRoles: ['holder'], + }), + ).rejects.toThrow(/Gasless transactions require direct access to a private key/); + }); + + it('throws when the caller address does not match the required role holder (case-insensitive compare still matches when equal)', async () => { + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue('0xDIFFERENTADDRESS00000000000000000000000'), + }); + + await expect( + prepareGaslessRun({ + network: 'sepolia', + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + paymasterAddress, + requiredRoles: ['holder'], + }), + ).rejects.toThrow(/is not the current holder/); + }); + + it('does not throw when the caller address matches the role holder in a different case', async () => { + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue(callerAddress.toUpperCase()), + }); + + await expect( + prepareGaslessRun({ + network: 'sepolia', + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + paymasterAddress, + requiredRoles: ['holder'], + }), + ).resolves.toBeDefined(); + }); + + it('calls checkGaslessEligibility with titleEscrowAddress (not registryAddress)', async () => { + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + await prepareGaslessRun({ + network: 'sepolia', + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + paymasterAddress, + requiredRoles: ['holder'], + }); + + expect(checkGaslessEligibilityMock).toHaveBeenCalledWith( + expect.objectContaining({ + titleEscrowAddress, + }), + ); + const callArgs = checkGaslessEligibilityMock.mock.calls[0][0] as any; + expect(callArgs.registryAddress).toBeUndefined(); + }); + + it('returns network, titleEscrowAddress, callerAddress, smartAccountClient on success', async () => { + const fakeSmartAccountClient = { __brand: 'smartAccountClient' }; + buildGaslessSmartAccountClientMock.mockResolvedValue({ + smartAccountClient: fakeSmartAccountClient, + }); + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + const result = await prepareGaslessRun({ + network: 'sepolia', + tokenRegistryAddress: '0xregistry0000000000000000000000000000000', + tokenId: '0x1', + paymasterAddress, + requiredRoles: ['holder'], + }); + + expect(result).toEqual({ + network: 'sepolia', + titleEscrowAddress, + callerAddress, + smartAccountClient: fakeSmartAccountClient, + }); + }); + }); + + describe('prepareGaslessRegistryRun', () => { + const callerAddress = '0xCaLLeR000000000000000000000000000000000'; + const paymasterAddress = '0xpaymaster000000000000000000000000000000'; + const tokenRegistryAddress = '0xregistry0000000000000000000000000000000'; + + beforeEach(() => { + buildGaslessSmartAccountClientMock.mockResolvedValue({ + smartAccountClient: { __brand: 'smartAccountClient' }, + }); + }); + + it('throws when the resolved wallet has no privateKey', async () => { + getWalletOrSignerMock.mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + await expect( + prepareGaslessRegistryRun({ + network: 'sepolia', + tokenRegistryAddress, + paymasterAddress, + }), + ).rejects.toThrow(/Gasless transactions require direct access to a private key/); + }); + + it('calls checkGaslessEligibility with registryAddress (not titleEscrowAddress) and never constructs a title escrow contract', async () => { + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + await prepareGaslessRegistryRun({ + network: 'sepolia', + tokenRegistryAddress, + paymasterAddress, + }); + + expect(checkGaslessEligibilityMock).toHaveBeenCalledWith( + expect.objectContaining({ + registryAddress: tokenRegistryAddress, + }), + ); + const callArgs = checkGaslessEligibilityMock.mock.calls[0][0] as any; + expect(callArgs.titleEscrowAddress).toBeUndefined(); + expect(ContractMock).not.toHaveBeenCalled(); + }); + + it('returns network, callerAddress, smartAccountClient (no titleEscrowAddress field) on success', async () => { + const fakeSmartAccountClient = { __brand: 'smartAccountClient' }; + buildGaslessSmartAccountClientMock.mockResolvedValue({ + smartAccountClient: fakeSmartAccountClient, + }); + getWalletOrSignerMock.mockResolvedValue({ + privateKey: '0xprivatekey', + getAddress: vi.fn().mockResolvedValue(callerAddress), + }); + + const result = await prepareGaslessRegistryRun({ + network: 'sepolia', + tokenRegistryAddress, + paymasterAddress, + }); + + expect(result).toEqual({ + network: 'sepolia', + callerAddress, + smartAccountClient: fakeSmartAccountClient, + }); + expect(result).not.toHaveProperty('titleEscrowAddress'); + }); + }); +}); diff --git a/tests/commands/gasless/config.test.ts b/tests/commands/gasless/config.test.ts new file mode 100644 index 0000000..a8f14b2 --- /dev/null +++ b/tests/commands/gasless/config.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { gaslessConstants } from '@trustvc/trustvc'; +import { sepolia, polygonAmoy } from 'viem/chains'; +import { NetworkCmdName } from '../../../src/utils'; +import { + isGaslessSupportedNetwork, + assertGaslessSupportedNetwork, + getViemChain, + getPimlicoApiKey, + getEip7702ImplementationAddress, + getGaslessRpcUrl, + getPimlicoBundlerUrl, + getGaslessFactoryAddress, +} from '../../../src/commands/gasless/config'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +vi.mock('../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + }; +}); + +describe('gasless/config', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('isGaslessSupportedNetwork', () => { + it('returns true for sepolia', () => { + expect(isGaslessSupportedNetwork(NetworkCmdName.Sepolia)).toBe(true); + }); + + it('returns true for amoy', () => { + expect(isGaslessSupportedNetwork(NetworkCmdName.Amoy)).toBe(true); + }); + + it('returns false for mainnet', () => { + expect(isGaslessSupportedNetwork(NetworkCmdName.Mainnet)).toBe(false); + }); + + it('returns false for an unrecognized network string', () => { + expect(isGaslessSupportedNetwork('not-a-network')).toBe(false); + }); + }); + + describe('assertGaslessSupportedNetwork', () => { + it('returns the network unchanged for sepolia', () => { + expect(assertGaslessSupportedNetwork(NetworkCmdName.Sepolia)).toBe(NetworkCmdName.Sepolia); + }); + + it('returns the network unchanged for amoy', () => { + expect(assertGaslessSupportedNetwork(NetworkCmdName.Amoy)).toBe(NetworkCmdName.Amoy); + }); + + it('throws for mainnet', () => { + expect(() => assertGaslessSupportedNetwork(NetworkCmdName.Mainnet)).toThrow( + /Gasless transactions are only supported on/, + ); + }); + + it('throws for an unsupported arbitrary network', () => { + expect(() => assertGaslessSupportedNetwork('mainnet')).toThrow(); + }); + }); + + describe('getViemChain', () => { + it('returns the sepolia viem chain for sepolia', () => { + expect(getViemChain(NetworkCmdName.Sepolia)).toBe(sepolia); + }); + + it('returns the polygonAmoy viem chain for amoy', () => { + expect(getViemChain(NetworkCmdName.Amoy)).toBe(polygonAmoy); + }); + }); + + describe('getPimlicoApiKey', () => { + it('throws when PIMLICO_API_KEY is not set', () => { + delete process.env.PIMLICO_API_KEY; + expect(() => getPimlicoApiKey()).toThrow( + 'PIMLICO_API_KEY environment variable is required for gasless transactions. ' + + 'Get an API key from https://dashboard.pimlico.io and set it in your environment.', + ); + }); + + it('returns the api key when set', () => { + process.env.PIMLICO_API_KEY = 'test-api-key'; + expect(getPimlicoApiKey()).toBe('test-api-key'); + }); + }); + + describe('getEip7702ImplementationAddress', () => { + const validAddress = '0x1234567890123456789012345678901234567890'; + + it('throws when neither scoped nor generic env var is set for sepolia', () => { + delete process.env.SEPOLIA_EIP7702_IMPL_ADDRESS; + delete process.env.EIP7702_IMPL_ADDRESS; + expect(() => getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toThrow( + /Missing EIP-7702 implementation address for sepolia/, + ); + }); + + it('throws when neither scoped nor generic env var is set for amoy', () => { + delete process.env.AMOY_EIP7702_IMPL_ADDRESS; + delete process.env.EIP7702_IMPL_ADDRESS; + expect(() => getEip7702ImplementationAddress(NetworkCmdName.Amoy)).toThrow( + /Missing EIP-7702 implementation address for amoy/, + ); + }); + + it('uses the scoped SEPOLIA_EIP7702_IMPL_ADDRESS env var when set', () => { + process.env.SEPOLIA_EIP7702_IMPL_ADDRESS = validAddress; + delete process.env.EIP7702_IMPL_ADDRESS; + expect(getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toBe(validAddress); + }); + + it('uses the scoped AMOY_EIP7702_IMPL_ADDRESS env var when set', () => { + process.env.AMOY_EIP7702_IMPL_ADDRESS = validAddress; + delete process.env.EIP7702_IMPL_ADDRESS; + expect(getEip7702ImplementationAddress(NetworkCmdName.Amoy)).toBe(validAddress); + }); + + it('falls back to the generic EIP7702_IMPL_ADDRESS when scoped is unset (sepolia)', () => { + delete process.env.SEPOLIA_EIP7702_IMPL_ADDRESS; + process.env.EIP7702_IMPL_ADDRESS = validAddress; + expect(getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toBe(validAddress); + }); + + it('falls back to the generic EIP7702_IMPL_ADDRESS when scoped is unset (amoy)', () => { + delete process.env.AMOY_EIP7702_IMPL_ADDRESS; + process.env.EIP7702_IMPL_ADDRESS = validAddress; + expect(getEip7702ImplementationAddress(NetworkCmdName.Amoy)).toBe(validAddress); + }); + + it('prefers the scoped env var over the generic one', () => { + const scopedAddress = '0x1111111111111111111111111111111111111111'; + process.env.SEPOLIA_EIP7702_IMPL_ADDRESS = scopedAddress; + process.env.EIP7702_IMPL_ADDRESS = validAddress; + expect(getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toBe(scopedAddress); + }); + + it('throws when the address is not a valid 0x + 40 hex char address', () => { + process.env.SEPOLIA_EIP7702_IMPL_ADDRESS = '0xnotavalidaddress'; + expect(() => getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toThrow( + /Invalid EIP-7702 implementation address/, + ); + }); + + it('throws when the address is missing the 0x prefix', () => { + process.env.SEPOLIA_EIP7702_IMPL_ADDRESS = '1234567890123456789012345678901234567890'; + expect(() => getEip7702ImplementationAddress(NetworkCmdName.Sepolia)).toThrow( + /Invalid EIP-7702 implementation address/, + ); + }); + }); + + describe('getGaslessRpcUrl', () => { + it('returns the SEPOLIA_RPC env var when set', () => { + process.env.SEPOLIA_RPC = 'https://custom-sepolia-rpc.example.com'; + expect(getGaslessRpcUrl(NetworkCmdName.Sepolia)).toBe( + 'https://custom-sepolia-rpc.example.com', + ); + }); + + it('returns the AMOY_RPC env var when set', () => { + process.env.AMOY_RPC = 'https://custom-amoy-rpc.example.com'; + expect(getGaslessRpcUrl(NetworkCmdName.Amoy)).toBe('https://custom-amoy-rpc.example.com'); + }); + + it('returns the hardcoded default for sepolia when env var unset', () => { + delete process.env.SEPOLIA_RPC; + expect(getGaslessRpcUrl(NetworkCmdName.Sepolia)).toBe( + 'https://ethereum-sepolia-rpc.publicnode.com', + ); + }); + + it('returns the hardcoded default for amoy when env var unset', () => { + delete process.env.AMOY_RPC; + expect(getGaslessRpcUrl(NetworkCmdName.Amoy)).toBe( + 'https://polygon-amoy-bor-rpc.publicnode.com', + ); + }); + }); + + describe('getPimlicoBundlerUrl', () => { + it('builds the exact expected URL for sepolia', () => { + expect(getPimlicoBundlerUrl(NetworkCmdName.Sepolia, 'my-api-key')).toBe( + `https://api.pimlico.io/v2/${sepolia.id}/rpc?apikey=my-api-key`, + ); + }); + + it('builds the exact expected URL for amoy', () => { + expect(getPimlicoBundlerUrl(NetworkCmdName.Amoy, 'my-api-key')).toBe( + `https://api.pimlico.io/v2/${polygonAmoy.id}/rpc?apikey=my-api-key`, + ); + }); + }); + + describe('getGaslessFactoryAddress', () => { + it('returns the real GASLESS_FACTORY_ADDRESS_SEPOLIA for sepolia', () => { + expect(getGaslessFactoryAddress(NetworkCmdName.Sepolia)).toBe( + gaslessConstants.GASLESS_FACTORY_ADDRESS_SEPOLIA, + ); + }); + + it('returns the real GASLESS_FACTORY_ADDRESS_AMOY for amoy', () => { + expect(getGaslessFactoryAddress(NetworkCmdName.Amoy)).toBe( + gaslessConstants.GASLESS_FACTORY_ADDRESS_AMOY, + ); + }); + }); +}); diff --git a/tests/commands/gasless/deploy/deploy-platform-paymaster.test.ts b/tests/commands/gasless/deploy/deploy-platform-paymaster.test.ts new file mode 100644 index 0000000..f37d46d --- /dev/null +++ b/tests/commands/gasless/deploy/deploy-platform-paymaster.test.ts @@ -0,0 +1,396 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { id } from 'ethers'; +import { + promptForDeployPlatformPaymasterInputs, + runDeployPlatformPaymaster, + handler, +} from '../../../../src/commands/gasless/deploy/deploy-platform-paymaster'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + deployPlatformPaymaster: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +vi.mock('../../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://sepolia.etherscan.io'), + getSupportedNetwork: vi.fn(() => ({ networkId: 11155111 })), + promptAddress: vi.fn(), + promptNetworkSelection: vi.fn(), + promptWalletSelection: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((network: string) => network), + getGaslessFactoryAddress: vi.fn(() => '0xfactoryfactoryfactoryfactoryfactoryfacto'), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +const VALID_SALT = `0x${'11'.repeat(32)}`; + +describe('gasless/deploy/deploy-platform-paymaster', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForDeployPlatformPaymasterInputs', () => { + it('returns the correct shape for a valid 0x-prefixed salt, keeping it as-is', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue('0x1234567890123456789012345678901234567890'); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce('1000'); + + const result = await promptForDeployPlatformPaymasterInputs(); + + expect(result.network).toBe('sepolia'); + expect(result.salt).toBe(VALID_SALT); + expect(result.platformAddress).toBe('0x1234567890123456789012345678901234567890'); + expect(result.dailyLimit).toBe('1000'); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('hashes a non-0x-prefixed salt into a bytes32 value distinct from the raw input', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue(undefined); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (prompts.input as any).mockResolvedValueOnce('my-plain-salt').mockResolvedValueOnce(''); + + const result = await promptForDeployPlatformPaymasterInputs(); + + expect(result.salt).not.toBe('my-plain-salt'); + expect(result.salt).toBe(id('my-plain-salt')); + expect(/^0x[a-fA-F0-9]{64}$/.test(result.salt)).toBe(true); + }); + + it('maps an empty daily limit input to undefined', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue(undefined); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce(''); + + const result = await promptForDeployPlatformPaymasterInputs(); + + expect(result.dailyLimit).toBeUndefined(); + }); + + it('keeps a "0" daily limit input as the string "0" (non-empty strings are truthy)', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue(undefined); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce('0'); + + const result = await promptForDeployPlatformPaymasterInputs(); + + expect(result.dailyLimit).toBe('0'); + }); + + describe('salt input validate', () => { + const getSaltValidate = async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue(undefined); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce(''); + + await promptForDeployPlatformPaymasterInputs(); + + return (prompts.input as MockedFunction).mock.calls[0][0].validate; + }; + + it('rejects a 0x-prefixed salt that is not exactly 64 hex characters', async () => { + const validate = await getSaltValidate(); + expect(validate('0x1234')).toBe( + 'A 0x-prefixed salt must be a 32-byte (64 hex character) value', + ); + }); + + it('accepts a valid 0x-prefixed 64-hex-character salt', async () => { + const validate = await getSaltValidate(); + expect(validate(VALID_SALT)).toBe(true); + }); + + it('accepts a non-0x-prefixed value as-is (hashed later, not at the prompt layer)', async () => { + const validate = await getSaltValidate(); + expect(validate('anything-goes-here')).toBe(true); + }); + }); + + describe('daily limit input validate', () => { + const getDailyLimitValidate = async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue(undefined); + (utils.promptWalletSelection as any).mockResolvedValue({}); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce(''); + + await promptForDeployPlatformPaymasterInputs(); + + return (prompts.input as MockedFunction).mock.calls[1][0].validate; + }; + + it('rejects a non-digit string', async () => { + const validate = await getDailyLimitValidate(); + expect(validate('abc')).toBe('Daily limit must be a non-negative integer (wei)'); + }); + + it('accepts a digit string', async () => { + const validate = await getDailyLimitValidate(); + expect(validate('12345')).toBe(true); + }); + + it('accepts an empty string', async () => { + const validate = await getDailyLimitValidate(); + expect(validate('')).toBe(true); + }); + }); + }); + + describe('runDeployPlatformPaymaster', () => { + let deployPlatformPaymasterMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let assertGaslessSupportedNetworkMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + deployPlatformPaymasterMock = trustvcModule.deployPlatformPaymaster as MockedFunction; + + const walletModule = await import('../../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + getWalletOrSignerMock.mockResolvedValue({ getAddress: vi.fn().mockResolvedValue('0xfrom') }); + + const configModule = await import('../../../../src/commands/gasless/config'); + assertGaslessSupportedNetworkMock = + configModule.assertGaslessSupportedNetwork as MockedFunction; + assertGaslessSupportedNetworkMock.mockImplementation((network: string) => network); + (configModule.getGaslessFactoryAddress as MockedFunction).mockReturnValue( + '0xfactoryfactoryfactoryfactoryfactoryfacto', + ); + (configModule.redactPimlicoApiKey as MockedFunction).mockImplementation( + (message: any) => message, + ); + + const utils = await import('../../../../src/utils'); + (utils.getSupportedNetwork as MockedFunction).mockReturnValue({ networkId: 11155111 }); + (utils.getEtherscanAddress as MockedFunction).mockReturnValue( + 'https://sepolia.etherscan.io', + ); + (utils.getErrorMessage as MockedFunction).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + }); + + const baseArgs = { + network: 'sepolia', + salt: VALID_SALT, + platformAddress: '0x1234567890123456789012345678901234567890', + dailyLimit: undefined, + encryptedWalletPath: './wallet.json', + }; + + it('deploys the paymaster with the correct arguments and returns the deployed address', async () => { + deployPlatformPaymasterMock.mockResolvedValue({ + txHash: '0xtxhash', + paymasterAddress: '0xpaymasteraddresspaymasteraddresspaymast', + }); + + const result = await runDeployPlatformPaymaster({ + ...baseArgs, + dailyLimit: '500', + }); + + expect(deployPlatformPaymasterMock).toHaveBeenCalledWith(expect.anything(), { + chainId: 11155111, + salt: VALID_SALT, + platformAddress: baseArgs.platformAddress, + factoryAddress: '0xfactoryfactoryfactoryfactoryfactoryfacto', + dailyLimit: 500n, + }); + expect(result).toBe('0xpaymasteraddresspaymasteraddresspaymast'); + }); + + it('passes dailyLimit as undefined when not provided', async () => { + deployPlatformPaymasterMock.mockResolvedValue({ + txHash: '0xtxhash', + paymasterAddress: '0xpaymasteraddresspaymasteraddresspaymast', + }); + + await runDeployPlatformPaymaster(baseArgs); + + expect(deployPlatformPaymasterMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ dailyLimit: undefined }), + ); + }); + + it('returns undefined and logs the error when the network is unsupported', async () => { + assertGaslessSupportedNetworkMock.mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy.'); + }); + + const result = await runDeployPlatformPaymaster(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith( + 'Gasless transactions are only supported on: sepolia, amoy.', + ); + expect(deployPlatformPaymasterMock).not.toHaveBeenCalled(); + }); + + it('returns undefined and logs the error when deployPlatformPaymaster rejects with an Error', async () => { + deployPlatformPaymasterMock.mockRejectedValue(new Error('deployment reverted')); + + const result = await runDeployPlatformPaymaster(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('deployment reverted'); + }); + + it('returns undefined and logs the error when deployPlatformPaymaster rejects with a non-Error', async () => { + deployPlatformPaymasterMock.mockRejectedValue('some string failure'); + + const result = await runDeployPlatformPaymaster(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('some string failure'); + }); + }); + + describe('handler', () => { + beforeEach(async () => { + vi.clearAllMocks(); + + const walletModule = await import('../../../../src/utils/wallet'); + (walletModule.getWalletOrSigner as MockedFunction).mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xfrom'), + }); + + const configModule = await import('../../../../src/commands/gasless/config'); + (configModule.assertGaslessSupportedNetwork as MockedFunction).mockImplementation( + (network: string) => network, + ); + (configModule.getGaslessFactoryAddress as MockedFunction).mockReturnValue( + '0xfactoryfactoryfactoryfactoryfactoryfacto', + ); + (configModule.redactPimlicoApiKey as MockedFunction).mockImplementation( + (message: any) => message, + ); + + const utils = await import('../../../../src/utils'); + (utils.getSupportedNetwork as MockedFunction).mockReturnValue({ networkId: 11155111 }); + (utils.getEtherscanAddress as MockedFunction).mockReturnValue( + 'https://sepolia.etherscan.io', + ); + (utils.getErrorMessage as MockedFunction).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + }); + + it('wires promptForDeployPlatformPaymasterInputs into runDeployPlatformPaymaster', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + const trustvcModule = await import('@trustvc/trustvc'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptAddress as any).mockResolvedValue('0x1234567890123456789012345678901234567890'); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (prompts.input as any).mockResolvedValueOnce(VALID_SALT).mockResolvedValueOnce(''); + (trustvcModule.deployPlatformPaymaster as MockedFunction).mockResolvedValue({ + txHash: '0xtxhash', + paymasterAddress: '0xpaymasteraddresspaymasteraddresspaymast', + }); + + const result = await handler(); + + expect(result).toBe('0xpaymasteraddresspaymasteraddresspaymast'); + }); + + it('catches Error exceptions thrown while prompting', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as any).mockRejectedValue(new Error('prompt blew up')); + + const result = await handler(); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('prompt blew up'); + }); + + it('catches non-Error exceptions thrown while prompting', async () => { + const utils = await import('../../../../src/utils'); + (utils.promptNetworkSelection as any).mockRejectedValue('raw string failure'); + + const result = await handler(); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('raw string failure'); + }); + }); +}); diff --git a/tests/commands/gasless/deploy/deploy-token-registry-gasless.test.ts b/tests/commands/gasless/deploy/deploy-token-registry-gasless.test.ts new file mode 100644 index 0000000..e97b11e --- /dev/null +++ b/tests/commands/gasless/deploy/deploy-token-registry-gasless.test.ts @@ -0,0 +1,424 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { v5ContractAddress } from '@trustvc/trustvc'; +import { + promptForDeployTokenRegistryGaslessInputs, + runDeployTokenRegistryGasless, +} from '../../../../src/commands/gasless/deploy/deploy-token-registry-gasless'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + deployTokenRegistryGasless: vi.fn(), + }; +}); + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(), + http: vi.fn(), + parseEventLogs: vi.fn(), + }; +}); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), +})); + +vi.mock('../../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://sepolia.etherscan.io'), + promptAddress: vi.fn(), + promptNetworkSelection: vi.fn(), + promptWalletSelection: vi.fn(), + }; +}); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((network: string) => network), + getGaslessRpcUrl: vi.fn(() => 'https://sepolia.example-rpc.com'), + getViemChain: vi.fn(() => ({ id: 11155111 })), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/client', () => ({ + buildGaslessSmartAccountClient: vi.fn(), +})); + +vi.mock('../../../../src/commands/gasless/eligibility', () => ({ + checkGaslessDeployEligibility: vi.fn(), +})); + +// Real chain ID with a known default TokenImplementation address in @trustvc/trustvc's real +// v5ContractAddress map (kept real per instructions, not stubbed). +const CHAIN_ID_WITH_DEFAULT = 11155111; +const DEFAULT_IMPL_ADDRESS = v5ContractAddress.TokenImplementation[CHAIN_ID_WITH_DEFAULT]; +// A chain ID with no entry in the real map at all. +const CHAIN_ID_WITHOUT_DEFAULT = 999999; + +describe('gasless/deploy/deploy-token-registry-gasless', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForDeployTokenRegistryGaslessInputs', () => { + beforeEach(async () => { + const utils = await import('../../../../src/utils'); + const configModule = await import('../../../../src/commands/gasless/config'); + + (utils.promptNetworkSelection as any).mockResolvedValue('sepolia'); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (configModule.getViemChain as MockedFunction).mockReturnValue({ + id: CHAIN_ID_WITH_DEFAULT, + }); + }); + + it('returns the correct shape for valid inputs', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (prompts.input as any).mockResolvedValueOnce('My Registry').mockResolvedValueOnce('MTR'); + (utils.promptAddress as any) + .mockResolvedValueOnce('0xpaymasterpaymasterpaymasterpaymasterpay') + .mockResolvedValueOnce('0ximplimplimplimplimplimplimplimplimplim'); + + const result = await promptForDeployTokenRegistryGaslessInputs(); + + expect(result.network).toBe('sepolia'); + expect(result.registryName).toBe('My Registry'); + expect(result.registrySymbol).toBe('MTR'); + expect(result.paymasterAddress).toBe('0xpaymasterpaymasterpaymasterpaymasterpay'); + expect(result.tokenRegistryImplAddress).toBe('0ximplimplimplimplimplimplimplimplimplim'); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('falls back to the default implementation address when the user leaves it blank', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (prompts.input as any).mockResolvedValueOnce('My Registry').mockResolvedValueOnce('MTR'); + (utils.promptAddress as any) + .mockResolvedValueOnce('0xpaymasterpaymasterpaymasterpaymasterpay') + .mockResolvedValueOnce(undefined); + + const result = await promptForDeployTokenRegistryGaslessInputs(); + + expect(result.tokenRegistryImplAddress).toBe(DEFAULT_IMPL_ADDRESS); + }); + + it('marks the impl address prompt optional when a default exists for the resolved chain', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + const configModule = await import('../../../../src/commands/gasless/config'); + + (configModule.getViemChain as MockedFunction).mockReturnValue({ + id: CHAIN_ID_WITH_DEFAULT, + }); + (prompts.input as any).mockResolvedValueOnce('My Registry').mockResolvedValueOnce('MTR'); + (utils.promptAddress as any) + .mockResolvedValueOnce('0xpaymasterpaymasterpaymasterpaymasterpay') + .mockResolvedValueOnce('0ximplimplimplimplimplimplimplimplimplim'); + + await promptForDeployTokenRegistryGaslessInputs(); + + const implCallArgs = (utils.promptAddress as MockedFunction).mock.calls[1]; + expect(implCallArgs[0]).toBe('token registry implementation'); + expect(implCallArgs[2]).toBe(true); + }); + + it('marks the impl address prompt required when no default exists for the resolved chain', async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + const configModule = await import('../../../../src/commands/gasless/config'); + + (configModule.getViemChain as MockedFunction).mockReturnValue({ + id: CHAIN_ID_WITHOUT_DEFAULT, + }); + (prompts.input as any).mockResolvedValueOnce('My Registry').mockResolvedValueOnce('MTR'); + (utils.promptAddress as any) + .mockResolvedValueOnce('0xpaymasterpaymasterpaymasterpaymasterpay') + .mockResolvedValueOnce('0xexplicitimpladdressexplicitimpladdres'); + + await promptForDeployTokenRegistryGaslessInputs(); + + const implCallArgs = (utils.promptAddress as MockedFunction).mock.calls[1]; + expect(implCallArgs[2]).toBe(false); + }); + + describe('registry name/symbol validate', () => { + const getValidateFns = async () => { + const utils = await import('../../../../src/utils'); + const prompts = await import('@inquirer/prompts'); + + (prompts.input as any).mockResolvedValueOnce('My Registry').mockResolvedValueOnce('MTR'); + (utils.promptAddress as any) + .mockResolvedValueOnce('0xpaymasterpaymasterpaymasterpaymasterpay') + .mockResolvedValueOnce('0ximplimplimplimplimplimplimplimplimplim'); + + await promptForDeployTokenRegistryGaslessInputs(); + + const calls = (prompts.input as MockedFunction).mock.calls as any[]; + return { nameValidate: calls[0][0].validate, symbolValidate: calls[1][0].validate }; + }; + + it('rejects a blank registry name', async () => { + const { nameValidate } = await getValidateFns(); + expect(nameValidate('')).toBe('Registry name is required'); + expect(nameValidate(' ')).toBe('Registry name is required'); + }); + + it('accepts a non-blank registry name', async () => { + const { nameValidate } = await getValidateFns(); + expect(nameValidate('My Registry')).toBe(true); + }); + + it('rejects a blank registry symbol', async () => { + const { symbolValidate } = await getValidateFns(); + expect(symbolValidate('')).toBe('Registry symbol is required'); + expect(symbolValidate(' ')).toBe('Registry symbol is required'); + }); + + it('accepts a non-blank registry symbol', async () => { + const { symbolValidate } = await getValidateFns(); + expect(symbolValidate('MTR')).toBe(true); + }); + }); + }); + + describe('runDeployTokenRegistryGasless', () => { + const baseArgs = { + network: 'sepolia', + registryName: 'My Registry', + registrySymbol: 'MTR', + paymasterAddress: '0xpaymasterpaymasterpaymasterpaymasterpay', + tokenRegistryImplAddress: '0ximplimplimplimplimplimplimplimplimplim', + encryptedWalletPath: './wallet.json', + }; + + const walletWithPrivateKey = { + privateKey: '0xprivatekeyprivatekeyprivatekeyprivatekeyprivatekeyprivatekey12', + getAddress: vi.fn().mockResolvedValue('0xcalleraddresscalleraddresscalleraddres'), + }; + + const smartAccountClientResult = { + smartAccountClient: { sendTransaction: vi.fn() }, + smartAccountAddress: '0xsmartaccountsmartaccountsmartaccounts', + }; + + beforeEach(async () => { + walletWithPrivateKey.getAddress.mockResolvedValue('0xcalleraddresscalleraddresscalleraddres'); + + const walletModule = await import('../../../../src/utils/wallet'); + (walletModule.getWalletOrSigner as MockedFunction).mockResolvedValue( + walletWithPrivateKey, + ); + + const configModule = await import('../../../../src/commands/gasless/config'); + (configModule.assertGaslessSupportedNetwork as MockedFunction).mockImplementation( + (network: any) => network, + ); + (configModule.getGaslessRpcUrl as MockedFunction).mockReturnValue( + 'https://sepolia.example-rpc.com', + ); + (configModule.getViemChain as MockedFunction).mockReturnValue({ + id: CHAIN_ID_WITH_DEFAULT, + }); + (configModule.redactPimlicoApiKey as MockedFunction).mockImplementation( + (message: any) => message, + ); + + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + (eligibilityModule.checkGaslessDeployEligibility as MockedFunction).mockResolvedValue( + undefined, + ); + + const clientModule = await import('../../../../src/commands/gasless/client'); + (clientModule.buildGaslessSmartAccountClient as MockedFunction).mockResolvedValue( + smartAccountClientResult, + ); + + const trustvcModule = await import('@trustvc/trustvc'); + (trustvcModule.deployTokenRegistryGasless as MockedFunction).mockResolvedValue( + '0xtransactionhashtransactionhashtransac', + ); + + const viemModule = await import('viem'); + (viemModule.createPublicClient as MockedFunction).mockReturnValue({ + getTransactionReceipt: vi.fn().mockResolvedValue({ logs: [{}] }), + }); + (viemModule.http as MockedFunction).mockReturnValue({}); + + const utils = await import('../../../../src/utils'); + (utils.getErrorMessage as MockedFunction).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + (utils.getEtherscanAddress as MockedFunction).mockReturnValue( + 'https://sepolia.etherscan.io', + ); + }); + + it('throws (internally caught) when the resolved wallet has no privateKey', async () => { + const walletModule = await import('../../../../src/utils/wallet'); + (walletModule.getWalletOrSigner as MockedFunction).mockResolvedValue({ + getAddress: vi.fn().mockResolvedValue('0xcalleraddresscalleraddresscalleraddres'), + }); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith( + expect.stringContaining('Gasless transactions require direct access to a private key'), + ); + + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + expect(eligibilityModule.checkGaslessDeployEligibility).not.toHaveBeenCalled(); + }); + + it('checks eligibility, builds the smart account client, deploys, and returns the deployed address when the event is found', async () => { + const viemModule = await import('viem'); + (viemModule.parseEventLogs as MockedFunction).mockReturnValue([ + { args: { deployed: '0xdeployedaddressdeployedaddressdeploye' } }, + ]); + + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + const clientModule = await import('../../../../src/commands/gasless/client'); + const trustvcModule = await import('@trustvc/trustvc'); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + expect(eligibilityModule.checkGaslessDeployEligibility).toHaveBeenCalledWith({ + network: 'sepolia', + paymasterAddress: baseArgs.paymasterAddress, + callerAddress: '0xcalleraddresscalleraddresscalleraddres', + }); + expect(clientModule.buildGaslessSmartAccountClient).toHaveBeenCalledWith({ + network: 'sepolia', + privateKey: walletWithPrivateKey.privateKey, + paymasterAddress: baseArgs.paymasterAddress, + }); + expect(trustvcModule.deployTokenRegistryGasless).toHaveBeenCalledWith( + baseArgs.registryName, + baseArgs.registrySymbol, + smartAccountClientResult.smartAccountClient, + { + paymasterAddress: baseArgs.paymasterAddress, + tokenRegistryImplAddress: baseArgs.tokenRegistryImplAddress, + }, + ); + expect(result).toBe('0xdeployedaddressdeployedaddressdeploye'); + + const signaleModule = await import('signale'); + expect(signaleModule.success).toHaveBeenCalledWith( + expect.stringContaining('deployed at 0xdeployedaddressdeployedaddressdeploye'), + ); + }); + + it('falls back to returning the raw transaction hash when no matching event is found', async () => { + const viemModule = await import('viem'); + (viemModule.parseEventLogs as MockedFunction).mockReturnValue([]); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + expect(result).toBe('0xtransactionhashtransactionhashtransac'); + + const signaleModule = await import('signale'); + expect(signaleModule.success).toHaveBeenCalledWith( + expect.stringContaining('deployment submitted'), + ); + expect(signaleModule.success).not.toHaveBeenCalledWith( + expect.stringContaining('deployed at'), + ); + }); + + it('returns undefined and logs the error when checkGaslessDeployEligibility rejects with an Error', async () => { + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + (eligibilityModule.checkGaslessDeployEligibility as MockedFunction).mockRejectedValue( + new Error('not whitelisted'), + ); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('not whitelisted'); + }); + + it('returns undefined and logs the error when checkGaslessDeployEligibility rejects with a non-Error', async () => { + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + (eligibilityModule.checkGaslessDeployEligibility as MockedFunction).mockRejectedValue( + 'raw failure', + ); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + + it('returns undefined and logs the error when deployTokenRegistryGasless rejects', async () => { + const trustvcModule = await import('@trustvc/trustvc'); + (trustvcModule.deployTokenRegistryGasless as MockedFunction).mockRejectedValue( + new Error('userop reverted'), + ); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('userop reverted'); + }); + + it('returns undefined and logs the error when buildGaslessSmartAccountClient rejects', async () => { + const clientModule = await import('../../../../src/commands/gasless/client'); + (clientModule.buildGaslessSmartAccountClient as MockedFunction).mockRejectedValue( + new Error('client build failed'), + ); + + const result = await runDeployTokenRegistryGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(result).toBeUndefined(); + expect(signaleModule.error).toHaveBeenCalledWith('client build failed'); + }); + }); +}); diff --git a/tests/commands/gasless/eligibility.test.ts b/tests/commands/gasless/eligibility.test.ts new file mode 100644 index 0000000..6733a2e --- /dev/null +++ b/tests/commands/gasless/eligibility.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + checkGaslessEligibility, + checkGaslessDeployEligibility, + checkGaslessMintEligibility, +} from '../../../src/commands/gasless/eligibility'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +vi.mock('../../../src/commands/gasless/config', () => ({ + getViemChain: vi.fn(), + getGaslessRpcUrl: vi.fn(), +})); + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(), + }; +}); + +const network = 'sepolia' as any; +const paymasterAddress = '0xpaymaster000000000000000000000000000000' as `0x${string}`; +const callerAddress = '0xcaller00000000000000000000000000000000' as `0x${string}`; +const titleEscrowAddress = '0xescrow000000000000000000000000000000000' as `0x${string}`; +const registryAddress = '0xregistry0000000000000000000000000000000' as `0x${string}`; + +describe('gasless/eligibility', () => { + let getCodeMock: MockedFunction; + let readContractMock: MockedFunction; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetAllMocks(); + + const configModule = await import('../../../src/commands/gasless/config'); + (configModule.getViemChain as MockedFunction).mockReturnValue({ id: 11155111 }); + (configModule.getGaslessRpcUrl as MockedFunction).mockReturnValue( + 'https://fake-rpc.example.com', + ); + + getCodeMock = vi.fn(); + readContractMock = vi.fn(); + + const viemModule = await import('viem'); + (viemModule.createPublicClient as MockedFunction).mockReturnValue({ + getCode: getCodeMock, + readContract: readContractMock, + }); + }); + + describe('checkGaslessEligibility', () => { + const allPassingReadContract = ({ functionName }: { functionName: string }) => { + switch (functionName) { + case 'authorizedTitleEscrows': + case 'authorizedRegistries': + case 'authorizedCallers': + return Promise.resolve(true); + case 'getUserDailySpend': + return Promise.resolve([0n, 100n, 0n]); + case 'getDeposit': + return Promise.resolve(1n); + default: + throw new Error(`Unexpected functionName: ${functionName}`); + } + }; + + it('throws when there is no contract code at the paymaster address', async () => { + getCodeMock.mockResolvedValue('0x'); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/No PlatformPaymaster contract found/); + + expect(readContractMock).not.toHaveBeenCalled(); + }); + + it('throws when there is undefined contract code at the paymaster address', async () => { + getCodeMock.mockResolvedValue(undefined); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/No PlatformPaymaster contract found/); + }); + + it('throws naming authorizedTitleEscrows when the title escrow is not authorized', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'authorizedTitleEscrows') return Promise.resolve(false); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/title escrow .* is not.*authorized/); + }); + + it('throws naming authorizedRegistries when the registry is not authorized', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'authorizedRegistries') return Promise.resolve(false); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + registryAddress, + }), + ).rejects.toThrow(/token registry .* is not.*authorized/); + }); + + it('throws when the caller is not authorized', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'authorizedCallers') return Promise.resolve(false); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/is not an authorized caller/); + }); + + it('throws when the daily limit has been reached (limit > 0 and spent >= limit)', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'getUserDailySpend') return Promise.resolve([100n, 100n, 0n]); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/daily sponsored-gas limit reached/); + }); + + it('never throws for the daily limit when limit === 0 (unlimited), even with huge spend', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'getUserDailySpend') { + return Promise.resolve([1_000_000_000n, 0n, 0n]); + } + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).resolves.toBeUndefined(); + }); + + it('throws when the paymaster has a zero deposit', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'getDeposit') return Promise.resolve(0n); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).rejects.toThrow(/no ETH deposited at the EntryPoint/); + }); + + it('resolves without throwing when all checks pass (title escrow variant)', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(allPassingReadContract); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + titleEscrowAddress, + }), + ).resolves.toBeUndefined(); + }); + + it('resolves without throwing when all checks pass (registry variant)', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(allPassingReadContract); + + await expect( + checkGaslessEligibility({ + network, + paymasterAddress, + callerAddress, + registryAddress, + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('checkGaslessDeployEligibility', () => { + const allPassingReadContract = ({ functionName }: { functionName: string }) => { + switch (functionName) { + case 'userWhitelist': + return Promise.resolve(1n); + case 'getDeposit': + return Promise.resolve(1n); + default: + throw new Error(`Unexpected functionName: ${functionName}`); + } + }; + + it('throws when there is no contract code at the paymaster address', async () => { + getCodeMock.mockResolvedValue('0x'); + + await expect( + checkGaslessDeployEligibility({ network, paymasterAddress, callerAddress }), + ).rejects.toThrow(/No PlatformPaymaster contract found/); + }); + + it('throws when the caller has zero deployment credits', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'userWhitelist') return Promise.resolve(0n); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessDeployEligibility({ network, paymasterAddress, callerAddress }), + ).rejects.toThrow(/no deployment\s*credits/); + }); + + it('throws when the paymaster has a zero deposit', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'getDeposit') return Promise.resolve(0n); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessDeployEligibility({ network, paymasterAddress, callerAddress }), + ).rejects.toThrow(/no ETH deposited at the EntryPoint/); + }); + + it('resolves without throwing when all checks pass', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(allPassingReadContract); + + await expect( + checkGaslessDeployEligibility({ network, paymasterAddress, callerAddress }), + ).resolves.toBeUndefined(); + }); + }); + + describe('checkGaslessMintEligibility', () => { + const allPassingReadContract = ({ functionName }: { functionName: string }) => { + switch (functionName) { + case 'hasRole': + return Promise.resolve(true); + case 'authorizedRegistries': + return Promise.resolve(true); + case 'getDeposit': + return Promise.resolve(1n); + default: + throw new Error(`Unexpected functionName: ${functionName}`); + } + }; + + it('throws when there is no contract code at the paymaster address', async () => { + getCodeMock.mockResolvedValue('0x'); + + await expect( + checkGaslessMintEligibility({ network, paymasterAddress, registryAddress }), + ).rejects.toThrow(/No PlatformPaymaster contract found/); + }); + + it('throws when the paymaster lacks MINTER_ROLE on the registry', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'hasRole') return Promise.resolve(false); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessMintEligibility({ network, paymasterAddress, registryAddress }), + ).rejects.toThrow(/does not hold MINTER_ROLE/); + }); + + it('throws when the registry is not authorized on the paymaster', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'authorizedRegistries') return Promise.resolve(false); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessMintEligibility({ network, paymasterAddress, registryAddress }), + ).rejects.toThrow(/is not authorized on the/); + }); + + it('throws when the paymaster has a zero deposit', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'getDeposit') return Promise.resolve(0n); + return allPassingReadContract({ functionName }); + }); + + await expect( + checkGaslessMintEligibility({ network, paymasterAddress, registryAddress }), + ).rejects.toThrow(/no ETH deposited at the EntryPoint/); + }); + + it('resolves without throwing when all checks pass', async () => { + getCodeMock.mockResolvedValue('0xcode'); + readContractMock.mockImplementation(allPassingReadContract); + + await expect( + checkGaslessMintEligibility({ network, paymasterAddress, registryAddress }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/accept-return-to-issuer.test.ts b/tests/commands/gasless/title-escrow/accept-return-to-issuer.test.ts new file mode 100644 index 0000000..64b3538 --- /dev/null +++ b/tests/commands/gasless/title-escrow/accept-return-to-issuer.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessAcceptReturnToIssuerInputs, + runAcceptReturnToIssuerGasless, +} from '../../../../src/commands/gasless/title-escrow/accept-return-to-issuer'; +import { TokenRegistryReturnedDocumentGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + acceptReturnedGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRegistryRun: vi.fn(), +})); + +describe('gasless/title-escrow/accept-return-to-issuer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessAcceptReturnToIssuerInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.tokenRegistry, + }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + (config.assertGaslessSupportedNetwork as any).mockImplementation((n: string) => n); + + const result = await promptForGaslessAcceptReturnToIssuerInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should reject when network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xabc' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: '0xabc', + tokenId: '0x1', + network: 'unsupported-network', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Unsupported network'); + }); + + await expect(promptForGaslessAcceptReturnToIssuerInputs()).rejects.toThrow( + 'Unsupported network', + ); + }); + }); + + describe('runAcceptReturnToIssuerGasless', () => { + let acceptReturnedGaslessMock: MockedFunction; + let prepareGaslessRegistryRunMock: MockedFunction; + + const baseArgs: TokenRegistryReturnedDocumentGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + acceptReturnedGaslessMock = trustvcModule.acceptReturnedGasless as MockedFunction; + + const commonModule = await import('../../../../src/commands/gasless/common'); + prepareGaslessRegistryRunMock = commonModule.prepareGaslessRegistryRun as MockedFunction; + }); + + it('should successfully run the gasless accept return to issuer flow', async () => { + prepareGaslessRegistryRunMock.mockResolvedValue({ + network: 'sepolia', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + acceptReturnedGaslessMock.mockResolvedValue('0xtxhash'); + + const result = await runAcceptReturnToIssuerGasless(baseArgs); + + const helpers = await import('../../../../src/commands/helpers'); + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(prepareGaslessRegistryRunMock).toHaveBeenCalledWith(baseArgs); + expect(prepareGaslessRegistryRunMock).toHaveBeenCalledWith( + expect.not.objectContaining({ requiredRoles: expect.anything() }), + ); + + expect(acceptReturnedGaslessMock).toHaveBeenCalledWith( + { tokenRegistryAddress: baseArgs.tokenRegistryAddress }, + { id: 'client' }, + { tokenId: baseArgs.tokenId, remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + }); + + it('should return undefined and log error message when prepareGaslessRegistryRun rejects with an Error', async () => { + prepareGaslessRegistryRunMock.mockRejectedValue(new Error('not eligible')); + + const result = await runAcceptReturnToIssuerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not eligible'); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log error message when the gasless call rejects with a non-Error', async () => { + prepareGaslessRegistryRunMock.mockResolvedValue({ + network: 'sepolia', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + acceptReturnedGaslessMock.mockRejectedValue('String error message'); + + const result = await runAcceptReturnToIssuerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/nominate.test.ts b/tests/commands/gasless/title-escrow/nominate.test.ts new file mode 100644 index 0000000..ce67caa --- /dev/null +++ b/tests/commands/gasless/title-escrow/nominate.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessNominateInputs, + runNominateGasless, +} from '../../../../src/commands/gasless/title-escrow/nominate'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + nominateGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/nominate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessNominateInputs', () => { + it('returns correctly mapped answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(mockInputs.paymasterAddress); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as MockedFunction).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessNominateInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newBeneficiary).toBe(mockInputs.newBeneficiary); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect(result.encryptedWalletPath).toBe('./wallet.json'); + }); + + it('rejects when the resolved network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xregistry' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: '0xregistry', + tokenId: '0xtokenid', + network: 'mainnet', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as MockedFunction).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy'); + }); + + await expect(promptForGaslessNominateInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy', + ); + expect(utils.promptAddress).not.toHaveBeenCalled(); + }); + }); + + describe('runNominateGasless', () => { + const baseArgs = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + encryptionKey: 'doc-id', + encryptedWalletPath: './wallet.json', + }; + + it('runs the gasless nomination and returns the transaction hash', async () => { + const helpers = await import('../../../../src/commands/helpers'); + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + const smartAccountClient = {}; + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient, + }); + (trustvc.nominateGasless as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await runNominateGasless(baseArgs); + + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + expect(common.prepareGaslessRun).toHaveBeenCalledWith( + expect.objectContaining({ ...baseArgs, requiredRoles: ['beneficiary'] }), + ); + expect(trustvc.nominateGasless).toHaveBeenCalledWith( + { titleEscrowAddress: '0xescrow' }, + smartAccountClient, + { newBeneficiaryAddress: baseArgs.newBeneficiary, remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + expect(result).toBe('0xtxhash'); + }); + + it('returns undefined and logs the message when prepareGaslessRun rejects with an Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + (common.prepareGaslessRun as MockedFunction).mockRejectedValue( + new Error('not the current beneficiary'), + ); + + const result = await runNominateGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current beneficiary'); + }); + + it('returns undefined and logs the raw value when the gasless call rejects with a non-Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient: {}, + }); + (trustvc.nominateGasless as MockedFunction).mockRejectedValue('raw failure'); + + const result = await runNominateGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/reject-return-to-issuer.test.ts b/tests/commands/gasless/title-escrow/reject-return-to-issuer.test.ts new file mode 100644 index 0000000..69103bd --- /dev/null +++ b/tests/commands/gasless/title-escrow/reject-return-to-issuer.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessRejectReturnToIssuerInputs, + runRejectReturnToIssuerGasless, +} from '../../../../src/commands/gasless/title-escrow/reject-return-to-issuer'; +import { TokenRegistryReturnedDocumentGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + rejectReturnedGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRegistryRun: vi.fn(), +})); + +describe('gasless/title-escrow/reject-return-to-issuer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessRejectReturnToIssuerInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.tokenRegistry, + }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + (config.assertGaslessSupportedNetwork as any).mockImplementation((n: string) => n); + + const result = await promptForGaslessRejectReturnToIssuerInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should reject when network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xabc' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: '0xabc', + tokenId: '0x1', + network: 'unsupported-network', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Unsupported network'); + }); + + await expect(promptForGaslessRejectReturnToIssuerInputs()).rejects.toThrow( + 'Unsupported network', + ); + }); + }); + + describe('runRejectReturnToIssuerGasless', () => { + let rejectReturnedGaslessMock: MockedFunction; + let prepareGaslessRegistryRunMock: MockedFunction; + + const baseArgs: TokenRegistryReturnedDocumentGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + rejectReturnedGaslessMock = trustvcModule.rejectReturnedGasless as MockedFunction; + + const commonModule = await import('../../../../src/commands/gasless/common'); + prepareGaslessRegistryRunMock = commonModule.prepareGaslessRegistryRun as MockedFunction; + }); + + it('should successfully run the gasless reject return to issuer flow', async () => { + prepareGaslessRegistryRunMock.mockResolvedValue({ + network: 'sepolia', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectReturnedGaslessMock.mockResolvedValue('0xtxhash'); + + const result = await runRejectReturnToIssuerGasless(baseArgs); + + const helpers = await import('../../../../src/commands/helpers'); + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(prepareGaslessRegistryRunMock).toHaveBeenCalledWith(baseArgs); + expect(prepareGaslessRegistryRunMock).toHaveBeenCalledWith( + expect.not.objectContaining({ requiredRoles: expect.anything() }), + ); + + expect(rejectReturnedGaslessMock).toHaveBeenCalledWith( + { tokenRegistryAddress: baseArgs.tokenRegistryAddress }, + { id: 'client' }, + { tokenId: baseArgs.tokenId, remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + }); + + it('should return undefined and log error message when prepareGaslessRegistryRun rejects with an Error', async () => { + prepareGaslessRegistryRunMock.mockRejectedValue(new Error('not eligible')); + + const result = await runRejectReturnToIssuerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not eligible'); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log error message when the gasless call rejects with a non-Error', async () => { + prepareGaslessRegistryRunMock.mockResolvedValue({ + network: 'sepolia', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectReturnedGaslessMock.mockRejectedValue('String error message'); + + const result = await runRejectReturnToIssuerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/reject-transfer-beneficiary.test.ts b/tests/commands/gasless/title-escrow/reject-transfer-beneficiary.test.ts new file mode 100644 index 0000000..83a4915 --- /dev/null +++ b/tests/commands/gasless/title-escrow/reject-transfer-beneficiary.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessRejectTransferOwnerInputs, + runRejectTransferOwnerGasless, +} from '../../../../src/commands/gasless/title-escrow/reject-transfer-beneficiary'; +import { TitleEscrowRejectTransferGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + rejectTransferBeneficiaryGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/reject-transfer-beneficiary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessRejectTransferOwnerInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.tokenRegistry, + }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + (config.assertGaslessSupportedNetwork as any).mockImplementation((n: string) => n); + + const result = await promptForGaslessRejectTransferOwnerInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should reject when network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xabc' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: '0xabc', + tokenId: '0x1', + network: 'unsupported-network', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Unsupported network'); + }); + + await expect(promptForGaslessRejectTransferOwnerInputs()).rejects.toThrow( + 'Unsupported network', + ); + }); + }); + + describe('runRejectTransferOwnerGasless', () => { + let rejectTransferBeneficiaryGaslessMock: MockedFunction; + let prepareGaslessRunMock: MockedFunction; + + const baseArgs: TitleEscrowRejectTransferGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + rejectTransferBeneficiaryGaslessMock = + trustvcModule.rejectTransferBeneficiaryGasless as MockedFunction; + + const commonModule = await import('../../../../src/commands/gasless/common'); + prepareGaslessRunMock = commonModule.prepareGaslessRun as MockedFunction; + }); + + it('should successfully run the gasless reject transfer owner flow', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferBeneficiaryGaslessMock.mockResolvedValue('0xtxhash'); + + const result = await runRejectTransferOwnerGasless(baseArgs); + + const helpers = await import('../../../../src/commands/helpers'); + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(prepareGaslessRunMock).toHaveBeenCalledWith({ + ...baseArgs, + requiredRoles: ['beneficiary'], + }); + + expect(rejectTransferBeneficiaryGaslessMock).toHaveBeenCalledWith( + { titleEscrowAddress: '0xTitleEscrow' }, + { id: 'client' }, + { remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + }); + + it('should return undefined and log error message when prepareGaslessRun rejects with an Error', async () => { + prepareGaslessRunMock.mockRejectedValue(new Error('not the current beneficiary')); + + const result = await runRejectTransferOwnerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current beneficiary'); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log error message when the gasless call rejects with a non-Error', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferBeneficiaryGaslessMock.mockRejectedValue('String error message'); + + const result = await runRejectTransferOwnerGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/reject-transfer-holder.test.ts b/tests/commands/gasless/title-escrow/reject-transfer-holder.test.ts new file mode 100644 index 0000000..577718b --- /dev/null +++ b/tests/commands/gasless/title-escrow/reject-transfer-holder.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessRejectTransferHolderInputs, + runRejectTransferHolderGasless, +} from '../../../../src/commands/gasless/title-escrow/reject-transfer-holder'; +import { TitleEscrowRejectTransferGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + rejectTransferHolderGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/reject-transfer-holder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessRejectTransferHolderInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.tokenRegistry, + }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + (config.assertGaslessSupportedNetwork as any).mockImplementation((n: string) => n); + + const result = await promptForGaslessRejectTransferHolderInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should reject when network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xabc' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: '0xabc', + tokenId: '0x1', + network: 'unsupported-network', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Unsupported network'); + }); + + await expect(promptForGaslessRejectTransferHolderInputs()).rejects.toThrow( + 'Unsupported network', + ); + }); + }); + + describe('runRejectTransferHolderGasless', () => { + let rejectTransferHolderGaslessMock: MockedFunction; + let prepareGaslessRunMock: MockedFunction; + + const baseArgs: TitleEscrowRejectTransferGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + rejectTransferHolderGaslessMock = + trustvcModule.rejectTransferHolderGasless as MockedFunction; + + const commonModule = await import('../../../../src/commands/gasless/common'); + prepareGaslessRunMock = commonModule.prepareGaslessRun as MockedFunction; + }); + + it('should successfully run the gasless reject transfer holder flow', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferHolderGaslessMock.mockResolvedValue('0xtxhash'); + + const result = await runRejectTransferHolderGasless(baseArgs); + + const helpers = await import('../../../../src/commands/helpers'); + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(prepareGaslessRunMock).toHaveBeenCalledWith({ + ...baseArgs, + requiredRoles: ['holder'], + }); + + expect(rejectTransferHolderGaslessMock).toHaveBeenCalledWith( + { titleEscrowAddress: '0xTitleEscrow' }, + { id: 'client' }, + { remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + }); + + it('should return undefined and log error message when prepareGaslessRun rejects with an Error', async () => { + prepareGaslessRunMock.mockRejectedValue(new Error('not the current holder')); + + const result = await runRejectTransferHolderGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder'); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log error message when the gasless call rejects with a non-Error', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferHolderGaslessMock.mockRejectedValue('String error message'); + + const result = await runRejectTransferHolderGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/reject-transfer-owners.test.ts b/tests/commands/gasless/title-escrow/reject-transfer-owners.test.ts new file mode 100644 index 0000000..59da56d --- /dev/null +++ b/tests/commands/gasless/title-escrow/reject-transfer-owners.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessRejectTransferOwnerHolderInputs, + runRejectTransferOwnerHolderGasless, +} from '../../../../src/commands/gasless/title-escrow/reject-transfer-owners'; +import { TitleEscrowRejectTransferGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + rejectTransferOwnersGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/reject-transfer-owners', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessRejectTransferOwnerHolderInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { + id: mockInputs.documentId, + tokenRegistry: mockInputs.tokenRegistry, + }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + (config.assertGaslessSupportedNetwork as any).mockImplementation((n: string) => n); + + const result = await promptForGaslessRejectTransferOwnerHolderInputs(); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should reject when network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xabc' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + document: mockDocument, + tokenRegistry: '0xabc', + tokenId: '0x1', + network: 'unsupported-network', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Unsupported network'); + }); + + await expect(promptForGaslessRejectTransferOwnerHolderInputs()).rejects.toThrow( + 'Unsupported network', + ); + }); + }); + + describe('runRejectTransferOwnerHolderGasless', () => { + let rejectTransferOwnersGaslessMock: MockedFunction; + let prepareGaslessRunMock: MockedFunction; + + const baseArgs: TitleEscrowRejectTransferGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x9999999999999999999999999999999999999999', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const trustvcModule = await import('@trustvc/trustvc'); + rejectTransferOwnersGaslessMock = + trustvcModule.rejectTransferOwnersGasless as MockedFunction; + + const commonModule = await import('../../../../src/commands/gasless/common'); + prepareGaslessRunMock = commonModule.prepareGaslessRun as MockedFunction; + }); + + it('should successfully run the gasless reject transfer owner and holder flow', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferOwnersGaslessMock.mockResolvedValue('0xtxhash'); + + const result = await runRejectTransferOwnerHolderGasless(baseArgs); + + const helpers = await import('../../../../src/commands/helpers'); + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(prepareGaslessRunMock).toHaveBeenCalledWith({ + ...baseArgs, + requiredRoles: ['holder', 'beneficiary'], + }); + + expect(rejectTransferOwnersGaslessMock).toHaveBeenCalledWith( + { titleEscrowAddress: '0xTitleEscrow' }, + { id: 'client' }, + { remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + }); + + it('should return undefined and log error message when prepareGaslessRun rejects with an Error', async () => { + prepareGaslessRunMock.mockRejectedValue(new Error('not the current holder/beneficiary')); + + const result = await runRejectTransferOwnerHolderGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder/beneficiary'); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log error message when the gasless call rejects with a non-Error', async () => { + prepareGaslessRunMock.mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xTitleEscrow', + callerAddress: '0xCaller', + smartAccountClient: { id: 'client' }, + }); + rejectTransferOwnersGaslessMock.mockRejectedValue('String error message'); + + const result = await runRejectTransferOwnerHolderGasless(baseArgs); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/return-to-issuer.test.ts b/tests/commands/gasless/title-escrow/return-to-issuer.test.ts new file mode 100644 index 0000000..ab5077c --- /dev/null +++ b/tests/commands/gasless/title-escrow/return-to-issuer.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessReturnToIssuerInputs, + runReturnToIssuerGasless, +} from '../../../../src/commands/gasless/title-escrow/return-to-issuer'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + returnToIssuerGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/return-to-issuer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessReturnToIssuerInputs', () => { + it('returns correctly mapped answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as MockedFunction).mockResolvedValue(mockInputs.paymasterAddress); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as MockedFunction).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessReturnToIssuerInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + expect(utils.promptAddress).toHaveBeenCalledTimes(1); + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect(result.encryptedWalletPath).toBe('./wallet.json'); + }); + + it('rejects when the resolved network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xregistry' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: '0xregistry', + tokenId: '0xtokenid', + network: 'mainnet', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as MockedFunction).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy'); + }); + + await expect(promptForGaslessReturnToIssuerInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy', + ); + expect(utils.promptAddress).not.toHaveBeenCalled(); + }); + }); + + describe('runReturnToIssuerGasless', () => { + const baseArgs = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + encryptionKey: 'doc-id', + encryptedWalletPath: './wallet.json', + }; + + it('runs the gasless return and returns the transaction hash', async () => { + const helpers = await import('../../../../src/commands/helpers'); + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + const smartAccountClient = {}; + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient, + }); + (trustvc.returnToIssuerGasless as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await runReturnToIssuerGasless(baseArgs); + + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + expect(common.prepareGaslessRun).toHaveBeenCalledWith( + expect.objectContaining({ ...baseArgs, requiredRoles: ['holder', 'beneficiary'] }), + ); + expect(trustvc.returnToIssuerGasless).toHaveBeenCalledWith( + { titleEscrowAddress: '0xescrow' }, + smartAccountClient, + { remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + expect(result).toBe('0xtxhash'); + }); + + it('returns undefined and logs the message when prepareGaslessRun rejects with an Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + (common.prepareGaslessRun as MockedFunction).mockRejectedValue( + new Error('not the current holder and beneficiary'), + ); + + const result = await runReturnToIssuerGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder and beneficiary'); + }); + + it('returns undefined and logs the raw value when the gasless call rejects with a non-Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient: {}, + }); + (trustvc.returnToIssuerGasless as MockedFunction).mockRejectedValue('raw failure'); + + const result = await runReturnToIssuerGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/transfer-beneficiary.test.ts b/tests/commands/gasless/title-escrow/transfer-beneficiary.test.ts new file mode 100644 index 0000000..ceaf400 --- /dev/null +++ b/tests/commands/gasless/title-escrow/transfer-beneficiary.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessTransferBeneficiaryInputs, + runTransferBeneficiaryGasless, +} from '../../../../src/commands/gasless/title-escrow/transfer-beneficiary'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferBeneficiaryGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/transfer-beneficiary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessTransferBeneficiaryInputs', () => { + it('returns correctly mapped answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce(mockInputs.newBeneficiary) + .mockResolvedValueOnce(mockInputs.paymasterAddress); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as MockedFunction).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessTransferBeneficiaryInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newBeneficiary).toBe(mockInputs.newBeneficiary); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect(result.encryptedWalletPath).toBe('./wallet.json'); + }); + + it('rejects when the resolved network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xregistry' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: '0xregistry', + tokenId: '0xtokenid', + network: 'mainnet', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as MockedFunction).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy'); + }); + + await expect(promptForGaslessTransferBeneficiaryInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy', + ); + expect(utils.promptAddress).not.toHaveBeenCalled(); + }); + }); + + describe('runTransferBeneficiaryGasless', () => { + const baseArgs = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newBeneficiary: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + encryptionKey: 'doc-id', + encryptedWalletPath: './wallet.json', + }; + + it('runs the gasless transfer and returns the transaction hash', async () => { + const helpers = await import('../../../../src/commands/helpers'); + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + const smartAccountClient = {}; + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient, + }); + (trustvc.transferBeneficiaryGasless as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await runTransferBeneficiaryGasless(baseArgs); + + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + expect(common.prepareGaslessRun).toHaveBeenCalledWith( + expect.objectContaining({ ...baseArgs, requiredRoles: ['holder'] }), + ); + expect(trustvc.transferBeneficiaryGasless).toHaveBeenCalledWith( + { titleEscrowAddress: '0xescrow' }, + smartAccountClient, + { newBeneficiaryAddress: baseArgs.newBeneficiary, remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + expect(result).toBe('0xtxhash'); + }); + + it('returns undefined and logs the message when prepareGaslessRun rejects with an Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + (common.prepareGaslessRun as MockedFunction).mockRejectedValue( + new Error('not the current holder'), + ); + + const result = await runTransferBeneficiaryGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder'); + }); + + it('returns undefined and logs the raw value when the gasless call rejects with a non-Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient: {}, + }); + (trustvc.transferBeneficiaryGasless as MockedFunction).mockRejectedValue('raw failure'); + + const result = await runTransferBeneficiaryGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/transfer-holder.test.ts b/tests/commands/gasless/title-escrow/transfer-holder.test.ts new file mode 100644 index 0000000..836c6b3 --- /dev/null +++ b/tests/commands/gasless/title-escrow/transfer-holder.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessTransferHolderInputs, + runTransferHolderGasless, +} from '../../../../src/commands/gasless/title-escrow/transfer-holder'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferHolderGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/transfer-holder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessTransferHolderInputs', () => { + it('returns correctly mapped answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.paymasterAddress); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as MockedFunction).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessTransferHolderInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect(result.encryptedWalletPath).toBe('./wallet.json'); + }); + + it('rejects when the resolved network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xregistry' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: '0xregistry', + tokenId: '0xtokenid', + network: 'mainnet', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as MockedFunction).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy'); + }); + + await expect(promptForGaslessTransferHolderInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy', + ); + expect(utils.promptAddress).not.toHaveBeenCalled(); + }); + }); + + describe('runTransferHolderGasless', () => { + const baseArgs = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newHolder: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x1111111111111111111111111111111111111111', + remark: 'Test remark', + encryptionKey: 'doc-id', + encryptedWalletPath: './wallet.json', + }; + + it('runs the gasless transfer and returns the transaction hash', async () => { + const helpers = await import('../../../../src/commands/helpers'); + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + const smartAccountClient = {}; + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient, + }); + (trustvc.transferHolderGasless as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await runTransferHolderGasless(baseArgs); + + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + expect(common.prepareGaslessRun).toHaveBeenCalledWith( + expect.objectContaining({ ...baseArgs, requiredRoles: ['holder'] }), + ); + expect(trustvc.transferHolderGasless).toHaveBeenCalledWith( + { titleEscrowAddress: '0xescrow' }, + smartAccountClient, + { holderAddress: baseArgs.newHolder, remarks: baseArgs.remark }, + { id: baseArgs.encryptionKey }, + ); + expect(result).toBe('0xtxhash'); + }); + + it('returns undefined and logs the message when prepareGaslessRun rejects with an Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + (common.prepareGaslessRun as MockedFunction).mockRejectedValue( + new Error('not the current holder'), + ); + + const result = await runTransferHolderGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder'); + }); + + it('returns undefined and logs the raw value when the gasless call rejects with a non-Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient: {}, + }); + (trustvc.transferHolderGasless as MockedFunction).mockRejectedValue('raw failure'); + + const result = await runTransferHolderGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + }); +}); diff --git a/tests/commands/gasless/title-escrow/transfer-owners.test.ts b/tests/commands/gasless/title-escrow/transfer-owners.test.ts new file mode 100644 index 0000000..d528ccb --- /dev/null +++ b/tests/commands/gasless/title-escrow/transfer-owners.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessTransferOwnersInputs, + runTransferOwnersGasless, +} from '../../../../src/commands/gasless/title-escrow/transfer-owners'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + transferOwnersGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((n: string) => n), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/common', () => ({ + prepareGaslessRun: vi.fn(), +})); + +describe('gasless/title-escrow/transfer-owners', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('promptForGaslessTransferOwnersInputs', () => { + it('returns correctly mapped answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x1111111111111111111111111111111111111111', + newHolder: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x2222222222222222222222222222222222222222', + remark: 'Test remark', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as MockedFunction) + .mockResolvedValueOnce(mockInputs.newOwner) + .mockResolvedValueOnce(mockInputs.newHolder) + .mockResolvedValueOnce(mockInputs.paymasterAddress); + (utils.promptWalletSelection as MockedFunction).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as MockedFunction).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessTransferOwnersInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.newOwner).toBe(mockInputs.newOwner); + expect(result.newHolder).toBe(mockInputs.newHolder); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect(result.encryptedWalletPath).toBe('./wallet.json'); + }); + + it('rejects when the resolved network is not gasless-supported', async () => { + const mockDocument = { id: 'doc-id', tokenRegistry: '0xregistry' }; + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + (utils.promptAndReadDocument as MockedFunction).mockResolvedValue(mockDocument); + (utils.extractDocumentInfo as MockedFunction).mockResolvedValue({ + tokenRegistry: '0xregistry', + tokenId: '0xtokenid', + network: 'mainnet', + documentId: 'doc-id', + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as MockedFunction).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy'); + }); + + await expect(promptForGaslessTransferOwnersInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy', + ); + expect(utils.promptAddress).not.toHaveBeenCalled(); + }); + }); + + describe('runTransferOwnersGasless', () => { + const baseArgs = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + newOwner: '0x1111111111111111111111111111111111111111', + newHolder: '0x0987654321098765432109876543210987654321', + paymasterAddress: '0x2222222222222222222222222222222222222222', + remark: 'Test remark', + encryptionKey: 'doc-id', + encryptedWalletPath: './wallet.json', + }; + + it('runs the gasless transfer and returns the transaction hash', async () => { + const helpers = await import('../../../../src/commands/helpers'); + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + const smartAccountClient = {}; + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient, + }); + (trustvc.transferOwnersGasless as MockedFunction).mockResolvedValue('0xtxhash'); + + const result = await runTransferOwnersGasless(baseArgs); + + expect(helpers.validateAndEncryptRemark).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + expect(common.prepareGaslessRun).toHaveBeenCalledWith( + expect.objectContaining({ ...baseArgs, requiredRoles: ['holder', 'beneficiary'] }), + ); + expect(trustvc.transferOwnersGasless).toHaveBeenCalledWith( + { titleEscrowAddress: '0xescrow' }, + smartAccountClient, + { + newBeneficiaryAddress: baseArgs.newOwner, + newHolderAddress: baseArgs.newHolder, + remarks: baseArgs.remark, + }, + { id: baseArgs.encryptionKey }, + ); + expect(result).toBe('0xtxhash'); + }); + + it('returns undefined and logs the message when prepareGaslessRun rejects with an Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + (common.prepareGaslessRun as MockedFunction).mockRejectedValue( + new Error('not the current holder and beneficiary'), + ); + + const result = await runTransferOwnersGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('not the current holder and beneficiary'); + }); + + it('returns undefined and logs the raw value when the gasless call rejects with a non-Error', async () => { + const common = await import('../../../../src/commands/gasless/common'); + const trustvc = await import('@trustvc/trustvc'); + + (common.prepareGaslessRun as MockedFunction).mockResolvedValue({ + network: 'sepolia', + titleEscrowAddress: '0xescrow', + callerAddress: '0xcaller', + smartAccountClient: {}, + }); + (trustvc.transferOwnersGasless as MockedFunction).mockRejectedValue('raw failure'); + + const result = await runTransferOwnersGasless(baseArgs); + + expect(result).toBeUndefined(); + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('raw failure'); + }); + }); +}); diff --git a/tests/commands/gasless/token-regitsry/mint.test.ts b/tests/commands/gasless/token-regitsry/mint.test.ts new file mode 100644 index 0000000..8e84051 --- /dev/null +++ b/tests/commands/gasless/token-regitsry/mint.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest'; +import { + promptForGaslessMintInputs, + runMintGasless, +} from '../../../../src/commands/gasless/token-regitsry/mint'; +import { TokenRegistryMintGaslessCommand } from '../../../../src/commands/gasless/types'; + +vi.mock('signale', async (importOriginal) => { + const originalSignale = await importOriginal(); + return { + ...originalSignale, + Signale: class MockSignale { + await = vi.fn(); + success = vi.fn(); + error = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + constructor() {} + }, + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + default: { + await: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + mintGasless: vi.fn(), + }; +}); + +vi.mock('../../../../src/utils', async (importOriginal) => { + const originalUtils = await importOriginal(); + return { + ...originalUtils, + extractDocumentInfo: vi.fn(), + getErrorMessage: vi.fn((e: any) => (e instanceof Error ? e.message : String(e))), + getEtherscanAddress: vi.fn(() => 'https://etherscan.io'), + promptAddress: vi.fn(), + promptAndReadDocument: vi.fn(), + promptRemark: vi.fn(), + promptWalletSelection: vi.fn(), + verifyDocumentSignature: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../../../../src/utils/wallet', () => ({ + getWalletOrSigner: vi.fn(), +})); + +vi.mock('../../../../src/commands/helpers', () => ({ + validateAndEncryptRemark: vi.fn().mockReturnValue('encrypted-remark'), +})); + +vi.mock('../../../../src/commands/gasless/config', () => ({ + assertGaslessSupportedNetwork: vi.fn((network: string) => network), + redactPimlicoApiKey: vi.fn((message: string) => message), +})); + +vi.mock('../../../../src/commands/gasless/client', () => ({ + buildGaslessSmartAccountClient: vi.fn(), +})); + +vi.mock('../../../../src/commands/gasless/eligibility', () => ({ + checkGaslessMintEligibility: vi.fn(), +})); + +describe('gasless/token-registry mint', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('promptForGaslessMintInputs', () => { + it('should return correct answers for valid inputs', async () => { + const mockInputs = { + network: 'sepolia', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + beneficiary: '0x1111111111111111111111111111111111111111', + holder: '0x2222222222222222222222222222222222222222', + paymasterAddress: '0x3333333333333333333333333333333333333333', + remark: 'Test remark', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (utils.promptAddress as any) + .mockResolvedValueOnce(mockInputs.beneficiary) + .mockResolvedValueOnce(mockInputs.holder) + .mockResolvedValueOnce(mockInputs.paymasterAddress); + (utils.promptWalletSelection as any).mockResolvedValue({ + encryptedWalletPath: './wallet.json', + }); + (utils.promptRemark as any).mockResolvedValue(mockInputs.remark); + + const result = await promptForGaslessMintInputs(); + + expect(config.assertGaslessSupportedNetwork).toHaveBeenCalledWith(mockInputs.network); + + expect(utils.promptAddress).toHaveBeenNthCalledWith(1, 'beneficiary', 'initial recipient'); + expect(utils.promptAddress).toHaveBeenNthCalledWith(2, 'holder', 'initial holder'); + expect(utils.promptAddress).toHaveBeenNthCalledWith( + 3, + 'paymaster', + 'PlatformPaymaster contract that will sponsor this gasless transaction', + ); + + expect(result.network).toBe(mockInputs.network); + expect(result.tokenRegistryAddress).toBe(mockInputs.tokenRegistry); + expect(result.tokenId).toBe(mockInputs.tokenId); + expect(result.beneficiary).toBe(mockInputs.beneficiary); + expect(result.holder).toBe(mockInputs.holder); + expect(result.paymasterAddress).toBe(mockInputs.paymasterAddress); + expect(result.remark).toBe(mockInputs.remark); + expect(result.encryptionKey).toBe(mockInputs.documentId); + expect((result as any).encryptedWalletPath).toBe('./wallet.json'); + }); + + it('should throw and not prompt for beneficiary/holder/paymaster when network is unsupported', async () => { + const mockInputs = { + network: 'mainnet', + tokenRegistry: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + documentId: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + }; + + const mockDocument = { id: mockInputs.documentId, tokenRegistry: mockInputs.tokenRegistry }; + + const utils = await import('../../../../src/utils'); + const config = await import('../../../../src/commands/gasless/config'); + + (utils.promptAndReadDocument as any).mockResolvedValue(mockDocument); + (utils.verifyDocumentSignature as any).mockResolvedValue(undefined); + (utils.extractDocumentInfo as any).mockResolvedValue({ + tokenRegistry: mockInputs.tokenRegistry, + tokenId: mockInputs.tokenId, + network: mockInputs.network, + documentId: mockInputs.documentId, + registryVersion: 'v5', + }); + (config.assertGaslessSupportedNetwork as any).mockImplementation(() => { + throw new Error('Gasless transactions are only supported on: sepolia, amoy.'); + }); + + await expect(promptForGaslessMintInputs()).rejects.toThrow( + 'Gasless transactions are only supported on: sepolia, amoy.', + ); + + expect(utils.promptAddress).not.toHaveBeenCalled(); + expect(utils.promptWalletSelection).not.toHaveBeenCalled(); + expect(utils.promptRemark).not.toHaveBeenCalled(); + }); + }); + + describe('runMintGasless', () => { + let mintGaslessMock: MockedFunction; + let getWalletOrSignerMock: MockedFunction; + let checkGaslessMintEligibilityMock: MockedFunction; + let buildGaslessSmartAccountClientMock: MockedFunction; + let validateAndEncryptRemarkMock: MockedFunction; + + const baseArgs: TokenRegistryMintGaslessCommand = { + network: 'sepolia', + tokenRegistryAddress: '0x1234567890123456789012345678901234567890', + tokenId: '0xabcdef1234567890', + beneficiary: '0x1111111111111111111111111111111111111111', + holder: '0x2222222222222222222222222222222222222222', + paymasterAddress: '0x3333333333333333333333333333333333333333', + remark: 'Test remark', + encryptionKey: 'urn:uuid:019b9ce6-5048-7669-b1bf-e15d1f085692', + encryptedWalletPath: './wallet.json', + }; + + beforeEach(async () => { + const utils = await import('../../../../src/utils'); + (utils.getErrorMessage as any).mockImplementation((e: any) => + e instanceof Error ? e.message : String(e), + ); + + const trustvcModule = await import('@trustvc/trustvc'); + mintGaslessMock = trustvcModule.mintGasless as MockedFunction; + + const walletModule = await import('../../../../src/utils/wallet'); + getWalletOrSignerMock = walletModule.getWalletOrSigner as MockedFunction; + + const eligibilityModule = await import('../../../../src/commands/gasless/eligibility'); + checkGaslessMintEligibilityMock = + eligibilityModule.checkGaslessMintEligibility as MockedFunction; + + const clientModule = await import('../../../../src/commands/gasless/client'); + buildGaslessSmartAccountClientMock = + clientModule.buildGaslessSmartAccountClient as MockedFunction; + + const helpersModule = await import('../../../../src/commands/helpers'); + validateAndEncryptRemarkMock = helpersModule.validateAndEncryptRemark as MockedFunction; + validateAndEncryptRemarkMock.mockReturnValue('encrypted-remark'); + + const configModule = await import('../../../../src/commands/gasless/config'); + (configModule.assertGaslessSupportedNetwork as any).mockImplementation( + (network: string) => network, + ); + (configModule.redactPimlicoApiKey as any).mockImplementation((message: string) => message); + + getWalletOrSignerMock.mockResolvedValue({ privateKey: '0xprivatekey' }); + checkGaslessMintEligibilityMock.mockResolvedValue(undefined); + buildGaslessSmartAccountClientMock.mockResolvedValue({ + smartAccountClient: { sendTransaction: vi.fn() }, + smartAccountAddress: '0xsmartaccount', + }); + mintGaslessMock.mockResolvedValue('0xtxhash'); + }); + + it('should throw and log an error when the resolved wallet has no privateKey (AWS KMS signer)', async () => { + getWalletOrSignerMock.mockResolvedValue({ provider: {} }); + + const result = await runMintGasless(baseArgs); + + expect(result).toBeUndefined(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith( + 'Gasless transactions require direct access to a private key (encrypted wallet file, --key, --key-file, or OA_PRIVATE_KEY). AWS KMS signers are not supported.', + ); + expect(checkGaslessMintEligibilityMock).not.toHaveBeenCalled(); + expect(buildGaslessSmartAccountClientMock).not.toHaveBeenCalled(); + expect(mintGaslessMock).not.toHaveBeenCalled(); + }); + + it('should successfully run the gasless mint end to end', async () => { + const result = await runMintGasless(baseArgs); + + expect(validateAndEncryptRemarkMock).toHaveBeenCalledWith( + baseArgs.remark, + baseArgs.encryptionKey, + ); + + expect(getWalletOrSignerMock).toHaveBeenCalledWith({ + network: baseArgs.network, + encryptedWalletPath: baseArgs.encryptedWalletPath, + key: baseArgs.key, + keyFile: baseArgs.keyFile, + }); + + expect(checkGaslessMintEligibilityMock).toHaveBeenCalledWith({ + network: baseArgs.network, + paymasterAddress: baseArgs.paymasterAddress, + registryAddress: baseArgs.tokenRegistryAddress, + }); + + expect(buildGaslessSmartAccountClientMock).toHaveBeenCalledWith({ + network: baseArgs.network, + privateKey: '0xprivatekey', + paymasterAddress: baseArgs.paymasterAddress, + }); + + expect(mintGaslessMock).toHaveBeenCalledWith( + { + paymasterAddress: baseArgs.paymasterAddress, + tokenRegistryAddress: baseArgs.tokenRegistryAddress, + }, + expect.anything(), + { + beneficiaryAddress: baseArgs.beneficiary, + holderAddress: baseArgs.holder, + tokenId: baseArgs.tokenId, + remarks: baseArgs.remark, + }, + { id: baseArgs.encryptionKey }, + ); + + expect(result).toBe('0xtxhash'); + + const signaleModule = await import('signale'); + expect(signaleModule.success).toHaveBeenCalledWith(expect.stringContaining(baseArgs.tokenId)); + expect(signaleModule.info).toHaveBeenCalledWith(expect.stringContaining('0xtxhash')); + }); + + it('adds a 0x prefix to an unprefixed hexadecimal tokenId before minting', async () => { + await runMintGasless({ ...baseArgs, tokenId: 'abcdef1234567890' }); + + expect(mintGaslessMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ tokenId: '0xabcdef1234567890' }), + expect.anything(), + ); + }); + + it('does not alter an already-prefixed numeric tokenId', async () => { + await runMintGasless({ ...baseArgs, tokenId: '0x1234567890' }); + + expect(mintGaslessMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ tokenId: '0x1234567890' }), + expect.anything(), + ); + }); + + it('should log an error and return undefined when eligibility check rejects with an Error', async () => { + checkGaslessMintEligibilityMock.mockRejectedValue( + new Error('This account cannot mint gaslessly: not authorized'), + ); + + const result = await runMintGasless(baseArgs); + + expect(result).toBeUndefined(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith( + 'This account cannot mint gaslessly: not authorized', + ); + expect(buildGaslessSmartAccountClientMock).not.toHaveBeenCalled(); + expect(mintGaslessMock).not.toHaveBeenCalled(); + }); + + it('should log an error and return undefined when mintGasless rejects with a non-Error', async () => { + mintGaslessMock.mockRejectedValue('String error message'); + + const result = await runMintGasless(baseArgs); + + expect(result).toBeUndefined(); + + const signaleModule = await import('signale'); + expect(signaleModule.error).toHaveBeenCalledWith('String error message'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d6613e0..9b17db7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", + "module": "esnext", "lib": ["ES2020"], - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "skipLibCheck": true, "strict": true,