From bd6523289388d2dd85d551c866964ed293fa3cb7 Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 16:54:44 +0800 Subject: [PATCH 1/6] chore: add test CI workflow, fix packaging for standalone installs - Add .github/workflows/test.yml running build + jest on every PR and push to main (repo previously had publish-only CI) - Move blakejs to dependencies (used by src/cardano/signer/cip-8.ts, was only available transitively) - Regenerate package-lock.json: it had drifted from package.json (jest-environment-jsdom tree missing), breaking npm ci - Add a CBOR round-trip spike test pinning @cardano-sdk serialization against known-good CIP-30 vectors Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 29 + package-lock.json | 1065 ++++++++++++++++++++++++++++++- package.json | 1 + test/cardano/cbor-spike.test.ts | 26 + 4 files changed, 1106 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 test/cardano/cbor-spike.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8cf09b5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +# This workflow runs the test suite and build on pull requests and pushes to main +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests + +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/package-lock.json b/package-lock.json index 3a80011..c56c4e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meshsdk/wallet", - "version": "2.0.0-beta.9", + "version": "2.0.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meshsdk/wallet", - "version": "2.0.0-beta.9", + "version": "2.0.0-beta.10", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.11", @@ -20,6 +20,7 @@ "bip32": "^5.0.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", + "blakejs": "^1.2.1", "ecpair": "^2.1.0", "json-bigint": "^1.0.0", "tiny-secp256k1": "^2.2.4" @@ -33,6 +34,7 @@ "dotenv": "^16.4.5", "eslint": "^8.57.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.4.1", "patch-package": "^8.0.1", "prettier": "^3.5.3", "ts-jest": "^29.1.4", @@ -42,6 +44,27 @@ "typescript": "^5.3.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1056,6 +1079,121 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dnsquery/dns-packet": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", @@ -2018,6 +2156,230 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -2079,6 +2441,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -3127,6 +3513,18 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz", @@ -3166,6 +3564,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3280,6 +3685,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3730,7 +4145,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", - "dev": true, "license": "MIT" }, "node_modules/bn.js": { @@ -4344,25 +4758,97 @@ "node": ">= 8" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/dedent": { + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", @@ -5590,6 +6076,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5608,6 +6107,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5626,6 +6153,19 @@ "node": ">=0.4" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5884,6 +6424,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6258,6 +6805,225 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -6690,6 +7456,105 @@ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7426,6 +8291,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7621,6 +8493,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/patch-package": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", @@ -8080,6 +8978,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", @@ -8301,6 +9215,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8372,6 +9293,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scalus": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.14.2.tgz", @@ -8712,6 +9653,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8833,6 +9781,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -8877,6 +9845,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9568,6 +10549,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9603,6 +10597,30 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9736,6 +10754,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 6497abf..29b9ccd 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "bip32": "^5.0.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", + "blakejs": "^1.2.1", "ecpair": "^2.1.0", "json-bigint": "^1.0.0", "tiny-secp256k1": "^2.2.4" diff --git a/test/cardano/cbor-spike.test.ts b/test/cardano/cbor-spike.test.ts new file mode 100644 index 0000000..e6accfb --- /dev/null +++ b/test/cardano/cbor-spike.test.ts @@ -0,0 +1,26 @@ +import { Serialization } from "@cardano-sdk/core"; + +// Phase 0 spike: confirm @cardano-sdk/core Serialization can round-trip +// known-good CIP-30 CBOR hex without loss, before building the CIP-30 +// adapter layer on top of it. Vectors reused from test/wallet/cip-30.test.ts. +describe("CBOR spike — @cardano-sdk/core Serialization round-trip", () => { + const utxosHex = [ + "828258202e20c10271bfcb5eac7ca90f0f66981042b66ffe088ec2e74d2244dacf1680c00082581d605867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f821a000ffb22a1581cd213852f90d323b1240774b96a653945ba0152213ff43a51fbd6162aa14b4d657368546f6b656e303101", + "828258206971384d6636b9258bb0f507201427cc6b7690d60a5eba11cca90359a18afe9e0082581d605867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f1a00124f80", + "828258200a61ee1efd7014ab2b2798354a49952a89b995d5086a2a3cc3b92bd830faad0a00a300581d605867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f011a000e77e803d8185182014e4d01000033222220051200120011", + ]; + + it.each(utxosHex)("round-trips TransactionUnspentOutput CBOR: %s", (hex) => { + const utxo = Serialization.TransactionUnspentOutput.fromCbor(hex); + expect(utxo.toCbor()).toEqual(hex); + }); + + it("round-trips a multi-asset Value CBOR", () => { + // getBalance-shaped CBOR: ADA + multiple native assets, lifted verbatim + // from the eternl getBalance() mock in test/wallet/cip-30.test.ts + const valueHex = + "821a9ded8bb6b0581c0648e3795e3402c4f93ffd7e76d7741a7e449ae1a769c0297b798813a24b4d657368546f6b656e3031034b4d657368546f6b656e303303581c0ba402c042775dfffedbd958cae3805a281bad34f46b5b6fd5c2c771a1494d657368546f6b656e01581c0c5d5669f4b2c0c7be0dc771b387c4f2b55396d8b47701f82476e930a14b4d657368546f6b656e303102581c2ae6f72c24d6e8866318b5d8512a7d6bd71f48421bfd5965aa6e8ea1a14b4d657368546f6b656e303101581c3341fe984c724915cebac76ff65251df422f21fc75587ddc5f578ad7a145506172747901581c418f4cdc47b56879028737b4c19a648a3e72776b3ff6edf6563aaac9a24b4d657368546f6b656e3031024b4d657368546f6b656e303302581c839680d91609accded1eca1dcfd2bd715b45ebe052321f52727ea091a1494d657368546f6b656e01581c974966e3b5f81f3bf32054c09bd4bae7f123d674eb3958925e5b3377a1496d657368202831302901581cb8df5676f026a61bc6435621df48217b3e652ea63f34e15113cbc456a14d546f6b656e303133323432333402581cc1d3d06522380d6380ec3e52ec6dfd96f0ce8005bb14df811455d20fa14b4d657368546f6b656e303102581cc69b981db7a65e339a6d783755f85a2e03afa1cece9714c55fe4c913a1445553444d1b00000006fc1469d5581cc76c35088ac826c8a0e6947c8ff78d8d4495789bc729419b3a334305a2493232322e6b756e7a61014a3232326a696e676c657301581cce5a4e6ea5819e96581a4b5680a4a5f94dec4337beade12e3851d01ca14b4d657368546f6b656e303102581cd213852f90d323b1240774b96a653945ba0152213ff43a51fbd6162aa24b4d657368546f6b656e3031064b4d657368546f6b656e303306581cd9312da562da182b02322fd8acb536f37eb9d29fba7c49dc17255527a1494d657368546f6b656e02581cee0b96031993a01dafa0c271439814426fd7d24395e96877cd8853f2a14f446f6c6c61722070726f766964657201"; + const value = Serialization.Value.fromCbor(valueHex); + expect(value.toCbor()).toEqual(valueHex); + }); +}); From 6049030c69629dc3be3be1d204222663fab7f12e Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 16:54:44 +0800 Subject: [PATCH 2/6] fix(cardano): make CIP-8 COSE node lookups robust to parsed CBOR getPublicKeyFromCoseKey, getPublicKey, getAddress and hashPayload compared CBOR nodes via serialized-JSON equality, which never matches nodes produced by Cbor.parse (they carry subCborRef/addInfos metadata that constructed nodes lack). Parsing a COSE key produced by our own getCoseKeyFromPublicKey threw 'Public key not found'. Replace all six comparison sites with semantic helpers (cborKeyIsText, cborKeyIsInt, cborIsSimpleTrue) that compare decoded values via instanceof + value equality. Caught by the new CIP-30 conformance suite; behavior otherwise unchanged. Co-Authored-By: Claude Fable 5 --- src/cardano/signer/cip-8.ts | 71 ++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/src/cardano/signer/cip-8.ts b/src/cardano/signer/cip-8.ts index 556bb88..da51b1b 100644 --- a/src/cardano/signer/cip-8.ts +++ b/src/cardano/signer/cip-8.ts @@ -16,7 +16,25 @@ import { RawCborMap, } from "@harmoniclabs/cbor"; import { blake2b } from "blakejs"; -import JSONBig from "json-bigint"; + +/** + * Semantic CBOR node comparisons. Parsed nodes carry internal metadata + * (e.g. `subCborRef`) that constructed nodes lack, so serialized-JSON + * equality between the two is unreliable — compare the decoded values. + */ +function cborKeyIsText(k: unknown, text: string): boolean { + return k instanceof CborText && k.text === text; +} + +function cborKeyIsInt(k: unknown, n: number | bigint): boolean { + return ( + (k instanceof CborUInt || k instanceof CborNegInt) && k.num === BigInt(n) + ); +} + +function cborIsSimpleTrue(v: unknown): boolean { + return v instanceof CborSimple && v.simple === true; +} class CoseSign1 { private protectedMap: CborMap; @@ -38,12 +56,7 @@ class CoseSign1 { this.payload = payload.payload; if ( - !this.unProtectedMap.map.find((value) => { - return ( - JSONBig.stringify(value.k) === - JSONBig.stringify(new CborText("hashed")) - ); - }) + !this.unProtectedMap.map.find((value) => cborKeyIsText(value.k, "hashed")) ) { this.unProtectedMap.map.push({ k: new CborText("hashed"), @@ -158,24 +171,14 @@ class CoseSign1 { if (!this.unProtectedMap) throw Error("Invalid unprotected map"); if (!this.payload) throw Error("Invalid payload"); - const hashedIndex = this.unProtectedMap.map.findIndex((value) => { - return ( - JSONBig.stringify(value.k) === JSONBig.stringify(new CborText("hashed")) - ); - }); + const hashedIndex = this.unProtectedMap.map.findIndex((value) => + cborKeyIsText(value.k, "hashed"), + ); const hashed = this.unProtectedMap.map[hashedIndex]; - if ( - hashed && - JSONBig.stringify(hashed.v) === JSONBig.stringify(new CborSimple(true)) - ) + if (hashed && cborIsSimpleTrue(hashed.v)) throw Error("Payload already hashed"); - if ( - hashed && - (JSONBig.stringify(hashed.v) === - JSONBig.stringify(new CborSimple(true))) != - false - ) + if (hashed && cborIsSimpleTrue(hashed.v) != false) throw Error("Invalid unprotected map"); this.unProtectedMap.map.splice(hashedIndex, 1); @@ -185,20 +188,17 @@ class CoseSign1 { } getAddress(): Buffer { - const address = this.protectedMap.map.find((value) => { - return ( - JSONBig.stringify(value.k) === - JSONBig.stringify(new CborText("address")) - ); - }); + const address = this.protectedMap.map.find((value) => + cborKeyIsText(value.k, "address"), + ); if (!address) throw Error("Address not found"); return Buffer.from((address.v as CborBytes).bytes); } getPublicKey(): Buffer { - const publicKey = this.protectedMap.map.find((value) => { - return JSONBig.stringify(value.k) === JSONBig.stringify(new CborUInt(4)); - }); + const publicKey = this.protectedMap.map.find((value) => + cborKeyIsInt(value.k, 4), + ); if (!publicKey) throw Error("Public key not found"); return Buffer.from((publicKey.v as CborBytes).bytes); } @@ -214,12 +214,9 @@ class CoseSign1 { const getPublicKeyFromCoseKey = (cbor: string): Buffer => { const decodedCoseKey = Cbor.parse(cbor) as CborMap; - const publicKeyEntry = decodedCoseKey.map.find((value) => { - return ( - JSONBig.stringify(value.k) === - JSONBig.stringify(new CborNegInt(BigInt(-2))) - ); - }); + const publicKeyEntry = decodedCoseKey.map.find((value) => + cborKeyIsInt(value.k, -2), + ); if (publicKeyEntry) { return Buffer.from((publicKeyEntry.v as CborBytes).bytes); From 3991660a8c9245aec3e2223a39edec99d16a4fe5 Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 16:55:06 +0800 Subject: [PATCH 3/6] feat(cardano): spec-exact CIP-30 adapter with simulated initial API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/cardano/cip30/ module wrapping any ICardanoWallet as a spec-exact CIP-30 surface, without changing existing wallet APIs: - errors.ts: full CIP-30 error taxonomy — APIError(-1..-4), PaginateError{maxSize}, TxSignError(1,2), DataSignError(1,2,3), TxSendError(1,2) — with {code, info} wire shapes and type guards - types.ts: ICip30Api, ICip30InitialApi, Paginate, Cip30Extension - cip30-api.ts: - createCip30Api(wallet): getUtxos(amount?, paginate?) with amount-coverage selection (coin + every multiasset) and null semantics, paginated getUsedAddresses, getCollateral({amount}) over pure-ADA utxos with null semantics, and every wallet error mapped to the spec taxonomy - createCip30Wallet({wallet, name, ...}): simulated initial API — apiVersion "1", isEnabled(), enable(extensions?) with requested-intersect-supported negotiation and a configurable approval hook for the Refused(-3) path Surgical wallet improvements behind the adapter: - signData now accepts bech32 or hex addresses (CIP-30 Address type) - getUsedAddresses derives used addresses from fetcher utxos when a fetcher is attached, falling back to the single derived address Co-Authored-By: Claude Fable 5 --- src/cardano/cip30/cip30-api.ts | 414 ++++++++++++++++++ src/cardano/cip30/errors.ts | 166 +++++++ src/cardano/cip30/types.ts | 68 +++ .../wallet/mesh/cardano-headless-wallet.ts | 53 ++- src/index.ts | 3 + 5 files changed, 691 insertions(+), 13 deletions(-) create mode 100644 src/cardano/cip30/cip30-api.ts create mode 100644 src/cardano/cip30/errors.ts create mode 100644 src/cardano/cip30/types.ts diff --git a/src/cardano/cip30/cip30-api.ts b/src/cardano/cip30/cip30-api.ts new file mode 100644 index 0000000..48b528f --- /dev/null +++ b/src/cardano/cip30/cip30-api.ts @@ -0,0 +1,414 @@ +import { Cardano, Serialization } from "@cardano-sdk/core"; +import { Cbor, CborUInt } from "@harmoniclabs/cbor"; + +import { ICardanoWallet } from "../interfaces/cardano-wallet"; +import { mergeValue } from "../utils/value"; +import { + APIErrorCode, + Cip30APIError, + Cip30DataSignError, + Cip30PaginateError, + Cip30TxSendError, + Cip30TxSignError, + DataSignErrorCode, + TxSendErrorCode, + TxSignErrorCode, +} from "./errors"; +import { Cip30Extension, ICip30Api, ICip30InitialApi, Paginate } from "./types"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Runs `fn`, mapping any error that isn't already one of our CIP-30 error + * classes to `Cip30APIError(InternalError)`. Errors we've already classified + * (thrown by validation below, or by a more specific wrapper) pass through + * untouched. + */ +async function wrapApiErrors(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if ( + error instanceof Cip30APIError || + error instanceof Cip30TxSignError || + error instanceof Cip30TxSendError || + error instanceof Cip30DataSignError || + error instanceof Cip30PaginateError + ) { + throw error; + } + throw new Cip30APIError(APIErrorCode.InternalError, errorMessage(error)); + } +} + +/** + * Slices `items` into the requested page, throwing spec-exact errors for + * invalid pagination input. Shared by `getUtxos()` and `getUsedAddresses()`. + */ +function paginateItems(items: T[], paginate?: Paginate): T[] { + if (!paginate) { + return items; + } + const { page, limit } = paginate; + if (!Number.isInteger(page) || page < 0) { + throw new Cip30APIError( + APIErrorCode.InvalidRequest, + `Invalid paginate.page: ${page}`, + ); + } + if (!Number.isInteger(limit) || limit <= 0) { + throw new Cip30APIError( + APIErrorCode.InvalidRequest, + `Invalid paginate.limit: ${limit}`, + ); + } + const start = page * limit; + if (page > 0 && start >= items.length) { + throw new Cip30PaginateError(items.length); + } + return items.slice(start, start + limit); +} + +/** + * Checks whether `total` covers `target`: coin must be >= target's coin, and + * every multiasset quantity in `target` must be met. + */ +function valueCovers( + total: Serialization.Value, + target: Serialization.Value, +): boolean { + if (total.coin() < target.coin()) { + return false; + } + const targetMultiasset = target.multiasset(); + if (targetMultiasset) { + const totalMultiasset = total.multiasset() ?? new Map(); + for (const [assetId, quantity] of targetMultiasset) { + if ((totalMultiasset.get(assetId) ?? 0n) < quantity) { + return false; + } + } + } + return true; +} + +/** + * Greedily accumulates UTxOs (in the order returned by the wallet) until + * their merged value covers `target`. Returns `null` if the full set of + * UTxOs still isn't enough. + */ +function selectUtxosForAmount( + utxosHex: string[], + target: Serialization.Value, +): string[] | null { + let total = new Serialization.Value(0n); + const selected: string[] = []; + for (const utxoHex of utxosHex) { + if (valueCovers(total, target)) { + break; + } + const utxo = Serialization.TransactionUnspentOutput.fromCbor(utxoHex); + total = mergeValue(total, utxo.output().amount()); + selected.push(utxoHex); + } + return valueCovers(total, target) ? selected : null; +} + +/** + * Decodes a plain CBOR-encoded unsigned integer, as used for + * `getCollateral({ amount })`'s `cbor` shape (unlike `getUtxos`' + * `amount`, this is not a full CBOR `Value`). + */ +function decodeCborCoin(hex: string): bigint { + const decoded = Cbor.parse(hex); + // CIP-30 `Coin` is non-negative — reject negative or non-integer CBOR. + if (decoded instanceof CborUInt) { + return decoded.num; + } + throw new Error(`Expected a plain CBOR unsigned int, got: ${hex}`); +} + +const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n; + +function isScriptAddress(addressStr: string): boolean { + let address: Cardano.Address | null = null; + try { + address = Cardano.Address.fromString(addressStr); + } catch { + // fall through to the InvalidRequest throw below + } + if (!address) { + // Malformed input is an API error, not a signing failure. + throw new Cip30APIError( + APIErrorCode.InvalidRequest, + `Invalid address: ${addressStr}`, + ); + } + const props = address.getProps(); + // Note: @cardano-sdk/core's RewardAddress.getProps() carries the stake + // credential in `paymentPart` (delegationPart is undefined for reward + // addresses), so paymentPart is the signing credential for every type. + const credential = + props.type === Cardano.AddressType.RewardKey || + props.type === Cardano.AddressType.RewardScript + ? (props.paymentPart ?? props.delegationPart) + : props.paymentPart; + return credential?.type === Cardano.CredentialType.ScriptHash; +} + +/** + * Wraps an `ICardanoWallet` (e.g. `CardanoHeadlessWallet`) as a spec-exact + * CIP-30 `ICip30Api`: amount-filtered/paginated `getUtxos`, pagination + + * `PaginateError` on `getUsedAddresses`, amount-covering `getCollateral`, + * and the full CIP-30 error taxonomy mapped from the wallet's plain `Error`s. + * + * Not exported for direct use by dApps — go through `createCip30Wallet()`, + * which negotiates extensions via `enable()` first. + */ +export function createCip30Api( + wallet: ICardanoWallet, + opts?: { extensions?: Cip30Extension[] }, +): ICip30Api { + const grantedExtensions = opts?.extensions ?? []; + + return { + async getExtensions() { + return grantedExtensions; + }, + + async getNetworkId() { + return wrapApiErrors(() => wallet.getNetworkId()); + }, + + async getUtxos(amount, paginate) { + return wrapApiErrors(async () => { + const utxosHex = await wallet.getUtxos(); + + let filtered: string[]; + if (amount !== undefined) { + let target: Serialization.Value; + try { + target = Serialization.Value.fromCbor(amount); + } catch (error) { + throw new Cip30APIError( + APIErrorCode.InvalidRequest, + `Invalid amount CBOR: ${errorMessage(error)}`, + ); + } + const selected = selectUtxosForAmount(utxosHex, target); + if (selected === null) { + return null; + } + filtered = selected; + } else { + filtered = utxosHex; + } + + return paginateItems(filtered, paginate); + }); + }, + + async getBalance() { + return wrapApiErrors(() => wallet.getBalance()); + }, + + async getUsedAddresses(paginate) { + return wrapApiErrors(async () => { + const used = await wallet.getUsedAddresses(); + return paginateItems(used, paginate); + }); + }, + + async getUnusedAddresses() { + return wrapApiErrors(() => wallet.getUnusedAddresses()); + }, + + async getChangeAddress() { + return wrapApiErrors(() => wallet.getChangeAddress()); + }, + + async getRewardAddresses() { + return wrapApiErrors(() => wallet.getRewardAddresses()); + }, + + async getCollateral(params) { + return wrapApiErrors(async () => { + let target = DEFAULT_COLLATERAL_LOVELACE; + if (params?.amount !== undefined) { + try { + target = decodeCborCoin(params.amount); + } catch (error) { + throw new Cip30APIError( + APIErrorCode.InvalidRequest, + `Invalid amount CBOR: ${errorMessage(error)}`, + ); + } + } + + const utxosHex = await wallet.getUtxos(); + const pureAda = utxosHex + .map((hex) => ({ + hex, + utxo: Serialization.TransactionUnspentOutput.fromCbor(hex), + })) + .filter( + ({ utxo }) => utxo.output().amount().multiasset() === undefined, + ) + .sort( + (a, b) => + Number(a.utxo.output().amount().coin()) - + Number(b.utxo.output().amount().coin()), + ); + + let total = 0n; + const selected: string[] = []; + for (const { hex, utxo } of pureAda) { + if (total >= target) { + break; + } + total += utxo.output().amount().coin(); + selected.push(hex); + } + return total >= target ? selected : null; + }); + }, + + async signTx(tx, partialSign = false) { + try { + return await wallet.signTx(tx, partialSign); + } catch (error) { + if (errorMessage(error).includes("Not all required signers found")) { + throw new Cip30TxSignError( + TxSignErrorCode.ProofGeneration, + errorMessage(error), + ); + } + throw new Cip30APIError( + APIErrorCode.InternalError, + errorMessage(error), + ); + } + }, + + async signData(addr, payload) { + if (isScriptAddress(addr)) { + throw new Cip30DataSignError( + DataSignErrorCode.AddressNotPK, + `Address ${addr} is a script address; only payment-key addresses can sign data`, + ); + } + try { + return await wallet.signData(addr, payload); + } catch (error) { + if (errorMessage(error).includes("No signer found")) { + throw new Cip30DataSignError( + DataSignErrorCode.ProofGeneration, + errorMessage(error), + ); + } + throw new Cip30APIError( + APIErrorCode.InternalError, + errorMessage(error), + ); + } + }, + + async submitTx(tx) { + try { + return await wallet.submitTx(tx); + } catch (error) { + // A wallet with no submitter configured is an internal setup error + // (same category as getUtxos() without a fetcher), not a node + // rejection — only actual submission failures map to TxSendError. + if (errorMessage(error).includes("No submitter provided")) { + throw new Cip30APIError( + APIErrorCode.InternalError, + errorMessage(error), + ); + } + throw new Cip30TxSendError( + TxSendErrorCode.Failure, + errorMessage(error), + ); + } + }, + }; +} + +export interface CreateCip30WalletOptions { + /** The underlying wallet to wrap. */ + wallet: ICardanoWallet; + /** Wallet name, surfaced as `ICip30InitialApi.name`. */ + name: string; + /** Wallet icon (data URI or URL), surfaced as `ICip30InitialApi.icon`. */ + icon?: string; + /** + * Extensions (beyond the base CIP-30 API) this wallet is willing to + * negotiate in `enable()`. Defaults to `[]`: the base CIP-30 API is always + * implicit in exposing this object at all, so `supportedExtensions` here + * only needs to list *additional* CIPs (e.g. CIP-95 governance) that this + * adapter doesn't otherwise implement. Callers that do wire up extra CIPs + * should pass them explicitly. + */ + supportedExtensions?: Cip30Extension[]; + /** + * Whether `enable()` auto-approves without prompting. Defaults to `true`, + * since a headless wallet has no UI to prompt with. Set to `false` (or + * supply `approve`) to exercise the `Refused(-3)` path, e.g. in tests. + */ + autoApprove?: boolean; + /** + * Optional approval hook, called with the negotiated (already + * intersected) extension list. Overrides `autoApprove` when provided. + */ + approve?: (extensions: Cip30Extension[]) => boolean | Promise; +} + +/** + * Wraps an `ICardanoWallet` as a simulated CIP-30 initial API + * (`window.cardano.`-shaped): `apiVersion`, `name`, `icon`, + * `supportedExtensions`, `isEnabled()`, and `enable()` extension negotiation. + */ +export function createCip30Wallet( + options: CreateCip30WalletOptions, +): ICip30InitialApi { + const supportedExtensions = options.supportedExtensions ?? []; + const autoApprove = options.autoApprove ?? true; + + let enabled = false; + + return { + apiVersion: "1", + name: options.name, + icon: options.icon ?? "", + supportedExtensions, + + async isEnabled() { + return enabled; + }, + + async enable(args) { + const requested = args?.extensions ?? []; + const granted = requested.filter((requestedExt) => + supportedExtensions.some( + (supportedExt) => supportedExt.cip === requestedExt.cip, + ), + ); + + const approved = options.approve + ? await options.approve(granted) + : autoApprove; + if (!approved) { + throw new Cip30APIError( + APIErrorCode.Refused, + "The wallet refused to enable the requested API", + ); + } + + enabled = true; + return createCip30Api(options.wallet, { extensions: granted }); + }, + }; +} diff --git a/src/cardano/cip30/errors.ts b/src/cardano/cip30/errors.ts new file mode 100644 index 0000000..5ae41f3 --- /dev/null +++ b/src/cardano/cip30/errors.ts @@ -0,0 +1,166 @@ +/** + * CIP-30 error taxonomy. + * + * The CIP-30 spec defines a handful of plain-object error shapes that dApps + * pattern-match on over the wire (postMessage / injected API boundary), so + * every error class here carries exactly the fields the spec requires and + * serializes to exactly the spec's wire shape via `toJSON()`. + * + * @see https://cips.cardano.org/cips/cip30/#errortypes + */ + +/** + * Error codes for the general `APIError`, returned by most CIP-30 endpoints + * once the initial API has been negotiated via `enable()`. + */ +export enum APIErrorCode { + InvalidRequest = -1, + InternalError = -2, + Refused = -3, + AccountChange = -4, +} + +/** + * Error codes for `TxSignError`, returned by `signTx()`. + */ +export enum TxSignErrorCode { + ProofGeneration = 1, + UserDeclined = 2, +} + +/** + * Error codes for `TxSendError`, returned by `submitTx()`. + */ +export enum TxSendErrorCode { + Refused = 1, + Failure = 2, +} + +/** + * Error codes for `DataSignError`, returned by `signData()`. + */ +export enum DataSignErrorCode { + ProofGeneration = 1, + AddressNotPK = 2, + UserDeclined = 3, +} + +/** + * General CIP-30 API error, thrown by `enable()` and most wallet-facing + * endpoints. Wire shape: `{ code, info }`. + */ +export class Cip30APIError extends Error { + readonly code: APIErrorCode; + readonly info: string; + + constructor(code: APIErrorCode, info: string) { + super(`[Cip30APIError] (${code}) ${info}`); + this.name = "Cip30APIError"; + this.code = code; + this.info = info; + } + + toJSON(): { code: APIErrorCode; info: string } { + return { code: this.code, info: this.info }; + } +} + +/** + * Thrown by `signTx()` when the wallet fails or refuses to sign a transaction. + * Wire shape: `{ code, info }`. + */ +export class Cip30TxSignError extends Error { + readonly code: TxSignErrorCode; + readonly info: string; + + constructor(code: TxSignErrorCode, info: string) { + super(`[Cip30TxSignError] (${code}) ${info}`); + this.name = "Cip30TxSignError"; + this.code = code; + this.info = info; + } + + toJSON(): { code: TxSignErrorCode; info: string } { + return { code: this.code, info: this.info }; + } +} + +/** + * Thrown by `submitTx()` when the wallet fails or refuses to submit a + * transaction. Wire shape: `{ code, info }`. + */ +export class Cip30TxSendError extends Error { + readonly code: TxSendErrorCode; + readonly info: string; + + constructor(code: TxSendErrorCode, info: string) { + super(`[Cip30TxSendError] (${code}) ${info}`); + this.name = "Cip30TxSendError"; + this.code = code; + this.info = info; + } + + toJSON(): { code: TxSendErrorCode; info: string } { + return { code: this.code, info: this.info }; + } +} + +/** + * Thrown by `signData()` when the wallet fails or refuses to sign data. + * Wire shape: `{ code, info }`. + */ +export class Cip30DataSignError extends Error { + readonly code: DataSignErrorCode; + readonly info: string; + + constructor(code: DataSignErrorCode, info: string) { + super(`[Cip30DataSignError] (${code}) ${info}`); + this.name = "Cip30DataSignError"; + this.code = code; + this.info = info; + } + + toJSON(): { code: DataSignErrorCode; info: string } { + return { code: this.code, info: this.info }; + } +} + +/** + * Thrown by paginated endpoints (`getUtxos()`, `getUsedAddresses()`, ...) + * when the caller requests a page size larger than the wallet supports. + * Unlike the other CIP-30 errors, this one carries no `code`/`info` — its + * wire shape is `{ maxSize }`. + */ +export class Cip30PaginateError extends Error { + readonly maxSize: number; + + constructor(maxSize: number) { + super(`[Cip30PaginateError] page size exceeds maxSize (${maxSize})`); + this.name = "Cip30PaginateError"; + this.maxSize = maxSize; + } + + toJSON(): { maxSize: number } { + return { maxSize: this.maxSize }; + } +} + +export function isApiError(error: unknown): error is Cip30APIError { + return error instanceof Cip30APIError; +} + +export function isTxSignError(error: unknown): error is Cip30TxSignError { + return error instanceof Cip30TxSignError; +} + +export function isTxSendError(error: unknown): error is Cip30TxSendError { + return error instanceof Cip30TxSendError; +} + +export function isDataSignError(error: unknown): error is Cip30DataSignError { + return error instanceof Cip30DataSignError; +} + +export function isPaginateError(error: unknown): error is Cip30PaginateError { + return error instanceof Cip30PaginateError; +} diff --git a/src/cardano/cip30/types.ts b/src/cardano/cip30/types.ts new file mode 100644 index 0000000..2857748 --- /dev/null +++ b/src/cardano/cip30/types.ts @@ -0,0 +1,68 @@ +import { DataSignature } from "@meshsdk/common"; + +/** + * CIP-30 pagination window. Zero-indexed per spec: `page: 0` is the first page. + */ +export type Paginate = { + page: number; + limit: number; +}; + +/** + * A CIP-30 extension descriptor, e.g. `{ cip: 95 }` for the CIP-95 governance + * extension. Named `Cip30Extension` (rather than `Extension`) to avoid + * clashing with @meshsdk/common's own `Extension` export. + */ +export type Cip30Extension = { + cip: number; +}; + +/** + * The full CIP-30 API surface, returned by `ICip30InitialApi.enable()`. + * + * @see https://cips.cardano.org/cips/cip30/ + */ +export interface ICip30Api { + /** + * @returns The extensions granted during `enable()`. + */ + getExtensions(): Promise; + getNetworkId(): Promise; + /** + * @param amount Optional `cbor` hex. If provided, only UTxOs whose + * merged value covers `amount` (coin and every multiasset quantity) are + * considered, and `null` is returned if no such set exists. + * @param paginate Optional pagination window, applied after amount + * filtering. + * @returns UTxOs in CBOR hex format, or `null` if `amount` can't be covered. + */ + getUtxos(amount?: string, paginate?: Paginate): Promise; + getBalance(): Promise; + getUsedAddresses(paginate?: Paginate): Promise; + getUnusedAddresses(): Promise; + getChangeAddress(): Promise; + getRewardAddresses(): Promise; + /** + * @param params Optional `{ amount }`, a `cbor` hex (plain CBOR + * unsigned int of lovelace). Defaults to 5 ADA when omitted. + * @returns Pure-ADA UTxOs in CBOR hex format covering the requested amount, + * or `null` if no such set exists. + */ + getCollateral(params?: { amount: string }): Promise; + signTx(tx: string, partialSign?: boolean): Promise; + signData(addr: string, payload: string): Promise; + submitTx(tx: string): Promise; +} + +/** + * The CIP-30 initial API, i.e. what a dApp sees at `window.cardano.` + * before calling `enable()`. + */ +export interface ICip30InitialApi { + readonly apiVersion: "1"; + readonly name: string; + readonly icon: string; + readonly supportedExtensions: Cip30Extension[]; + isEnabled(): Promise; + enable(args?: { extensions?: Cip30Extension[] }): Promise; +} diff --git a/src/cardano/wallet/mesh/cardano-headless-wallet.ts b/src/cardano/wallet/mesh/cardano-headless-wallet.ts index 5c64cf7..6f0fe80 100644 --- a/src/cardano/wallet/mesh/cardano-headless-wallet.ts +++ b/src/cardano/wallet/mesh/cardano-headless-wallet.ts @@ -1,6 +1,13 @@ import { Cardano, Serialization, setInConwayEra } from "@cardano-sdk/core"; +import { HexBlob } from "@cardano-sdk/util"; -import { DataSignature, IFetcher, ISubmitter, UTxO } from "@meshsdk/common"; +import { + DataSignature, + IFetcher, + isHexString, + ISubmitter, + UTxO, +} from "@meshsdk/common"; import { CardanoInMemoryBip32 } from "../../../bip32/cardano-in-memory-bip32"; import { AddressType } from "../../address/cardano-address"; @@ -279,15 +286,28 @@ export class CardanoHeadlessWallet implements ICardanoWallet { /** * Get the used addresses for the wallet. * - * NOTE: This method completely deviates from CIP-30 getUsedAddresses, as this wallet is stateless - * it is impossible to track which addresses have been used. This method simply returns the wallet's main address. + * When a fetcher is configured, "used" is derived from the distinct set of addresses + * holding the wallet's UTxOs (i.e. addresses with on-chain activity). Without a fetcher, + * or if the fetcher finds no UTxOs, this falls back to the wallet's main address, since + * a stateless wallet has no other way to know which addresses have been used. * * It will be effective to be used as a single address wallet. * * @returns {Promise} A promise that resolves to an array of used addresses in hex format */ async getUsedAddresses(): Promise { - //TODO: Should iterate over all utxos to get the used addresses + if (this.fetcher) { + const utxos = await this.fetchAccountUtxos(); + if (utxos.length > 0) { + const usedBech32Addresses = new Set( + utxos.map((utxo) => utxo.output.address), + ); + return Array.from(usedBech32Addresses).map((bech32Address) => + Cardano.Address.fromBech32(bech32Address).toBytes().toString(), + ); + } + } + const address = await this.addressManager.getNextAddress( this.walletAddressType, ); @@ -379,20 +399,23 @@ export class CardanoHeadlessWallet implements ICardanoWallet { return await CardanoSigner.signTx(tx, signers, false); } - async signData(addressBech32: string, data: string): Promise { - let targetAddressBech32 = addressBech32; - if (!targetAddressBech32) { - const address = await this.addressManager.getNextAddress( + async signData(address: string, data: string): Promise { + let targetAddress = address; + if (!targetAddress) { + const nextAddress = await this.addressManager.getNextAddress( this.walletAddressType, ); - targetAddressBech32 = address.getAddressBech32(); + targetAddress = nextAddress.getAddressBech32(); } - const address = Cardano.Address.fromBech32(targetAddressBech32); - if (!address) { + // Accept either bech32 (the historical shape) or hex (per CIP-30) addresses. + const resolvedAddress = isHexString(targetAddress) + ? Cardano.Address.fromBytes(HexBlob(targetAddress)) + : Cardano.Address.fromBech32(targetAddress); + if (!resolvedAddress) { throw new Error("[CardanoWallet] Invalid address"); } - const addressProps = address.getProps(); + const addressProps = resolvedAddress.getProps(); let credentialHash: string; if ( @@ -417,7 +440,11 @@ export class CardanoHeadlessWallet implements ICardanoWallet { ); } - return await CardanoSigner.signData(data, address.toBytes(), signer); + return await CardanoSigner.signData( + data, + resolvedAddress.toBytes(), + signer, + ); } public async fetchAccountUtxos(): Promise { diff --git a/src/index.ts b/src/index.ts index 76c651a..e9b4f1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,9 @@ export * from "./cardano/wallet/browser/mesh-browser-wallet"; export * from "./cardano/wallet/mesh/cardano-headless-wallet"; export * from "./cardano/wallet/mesh/mesh-wallet"; export * from "./cardano/interfaces/cardano-wallet"; +export * from "./cardano/cip30/errors"; +export * from "./cardano/cip30/types"; +export * from "./cardano/cip30/cip30-api"; export { AddressPurpose, AddressType as BitcoinAddressType, From 98eaddfed5242cd8c3d6522b3c636dad04966d6a Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 16:55:06 +0800 Subject: [PATCH 4/6] test(cardano): CIP-30 conformance suite and provider-attachment proof - One describe per CIP-30 spec endpoint (initial API included): CBOR round-trips, pagination boundaries + PaginateError{maxSize}, per-failure-mode error codes, null semantics, extension negotiation - Real cryptography, not shape checks: Ed25519 witness signatures verified against the tx body hash; COSE_Sign1 verified via CoseSign1.verifySignature; COSE_Key structure (kty/alg/crv/x) asserted per CIP-8; hex and bech32 signData parity - Provider attachment (A3): identical results from OfflineFetcher and a hand-rolled array-backed IFetcher, plus ISubmitter swap - Shared fixtures extracted to test/cardano/cip30/fixtures.ts Co-Authored-By: Claude Fable 5 --- test/cardano/cip30/cip30-api.test.ts | 209 +++++++++++ .../cip30/conformance-endpoints.test.ts | 355 ++++++++++++++++++ .../cip30/conformance-initial-api.test.ts | 215 +++++++++++ .../cardano/cip30/conformance-signing.test.ts | 230 ++++++++++++ test/cardano/cip30/errors.test.ts | 198 ++++++++++ test/cardano/cip30/fixtures.ts | 324 ++++++++++++++++ .../cardano/cip30/provider-attachment.test.ts | 91 +++++ 7 files changed, 1622 insertions(+) create mode 100644 test/cardano/cip30/cip30-api.test.ts create mode 100644 test/cardano/cip30/conformance-endpoints.test.ts create mode 100644 test/cardano/cip30/conformance-initial-api.test.ts create mode 100644 test/cardano/cip30/conformance-signing.test.ts create mode 100644 test/cardano/cip30/errors.test.ts create mode 100644 test/cardano/cip30/fixtures.ts create mode 100644 test/cardano/cip30/provider-attachment.test.ts diff --git a/test/cardano/cip30/cip30-api.test.ts b/test/cardano/cip30/cip30-api.test.ts new file mode 100644 index 0000000..44eba5b --- /dev/null +++ b/test/cardano/cip30/cip30-api.test.ts @@ -0,0 +1,209 @@ +import { Serialization } from "@cardano-sdk/core"; + +import { + createCip30Api, + createCip30Wallet, +} from "../../../src/cardano/cip30/cip30-api"; +import { APIErrorCode } from "../../../src/cardano/cip30/errors"; +import { + coinCbor, + createWallet, + SCRIPT_ADDRESS_BECH32, +} from "./fixtures"; + +describe("createCip30Api", () => { + describe("getUtxos", () => { + it("returns all utxos when no amount or paginate is given", async () => { + const api = createCip30Api(await createWallet()); + const utxos = await api.getUtxos(); + expect(utxos).not.toBeNull(); + expect(utxos).toHaveLength(6); + }); + + it("greedily selects utxos covering the requested amount", async () => { + const api = createCip30Api(await createWallet()); + const target = new Serialization.Value(1_000_000_000n); + const utxos = await api.getUtxos(target.toCbor()); + expect(utxos).not.toBeNull(); + const total = utxos!.reduce( + (sum, hex) => + sum + + Serialization.TransactionUnspentOutput.fromCbor(hex) + .output() + .amount() + .coin(), + 0n, + ); + expect(total).toBeGreaterThanOrEqual(1_000_000_000n); + }); + + it("returns null when the amount can't be covered by any combination of utxos", async () => { + const api = createCip30Api(await createWallet()); + const target = new Serialization.Value(10_000_000_000_000n); + const utxos = await api.getUtxos(target.toCbor()); + expect(utxos).toBeNull(); + }); + + it("applies pagination after amount filtering", async () => { + const api = createCip30Api(await createWallet()); + const page = await api.getUtxos(undefined, { page: 0, limit: 2 }); + expect(page).toHaveLength(2); + }); + + it("throws Cip30PaginateError with maxSize when the page is out of range", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.getUtxos(undefined, { page: 10, limit: 2 }), + ).rejects.toMatchObject({ maxSize: 6 }); + }); + + it("throws InvalidRequest for a negative page or non-positive limit", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.getUtxos(undefined, { page: -1, limit: 2 }), + ).rejects.toMatchObject({ code: APIErrorCode.InvalidRequest }); + await expect( + api.getUtxos(undefined, { page: 0, limit: 0 }), + ).rejects.toMatchObject({ code: APIErrorCode.InvalidRequest }); + }); + + it("throws InvalidRequest for malformed amount CBOR", async () => { + const api = createCip30Api(await createWallet()); + await expect(api.getUtxos("zz")).rejects.toMatchObject({ + code: APIErrorCode.InvalidRequest, + }); + }); + }); + + describe("getUsedAddresses", () => { + it("paginates the fetcher-derived used addresses, reusing PaginateError semantics", async () => { + const api = createCip30Api(await createWallet()); + const used = await api.getUsedAddresses(); + expect(used).toHaveLength(2); + + const page0 = await api.getUsedAddresses({ page: 0, limit: 1 }); + expect(page0).toHaveLength(1); + + await expect( + api.getUsedAddresses({ page: 5, limit: 1 }), + ).rejects.toMatchObject({ maxSize: 2 }); + }); + }); + + describe("getCollateral", () => { + it("selects the smallest pure-ADA utxos covering the default 5 ADA", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral(); + expect(collateral).not.toBeNull(); + const total = collateral!.reduce( + (sum, hex) => + sum + + Serialization.TransactionUnspentOutput.fromCbor(hex) + .output() + .amount() + .coin(), + 0n, + ); + expect(total).toBeGreaterThanOrEqual(5_000_000n); + for (const hex of collateral!) { + expect( + Serialization.TransactionUnspentOutput.fromCbor(hex) + .output() + .amount() + .multiasset(), + ).toBeUndefined(); + } + }); + + it("returns null when the requested amount exceeds available pure-ADA", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral({ + amount: coinCbor(10_000_000_000_000n), + }); + expect(collateral).toBeNull(); + }); + }); + + describe("getExtensions", () => { + it("defaults to an empty array when created without granted extensions", async () => { + const api = createCip30Api(await createWallet()); + expect(await api.getExtensions()).toEqual([]); + }); + }); + + describe("signData", () => { + it("rejects with AddressNotPK for a script address, without calling the wallet", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.signData(SCRIPT_ADDRESS_BECH32, "68656c6c6f"), + ).rejects.toMatchObject({ code: 2 }); // DataSignErrorCode.AddressNotPK + }); + }); + + describe("error mapping", () => { + it("maps a missing fetcher to Cip30APIError(InternalError)", async () => { + const api = createCip30Api(await createWallet(false)); + await expect(api.getUtxos()).rejects.toMatchObject({ + code: APIErrorCode.InternalError, + }); + }); + }); +}); + +describe("createCip30Wallet", () => { + it("exposes apiVersion, name and starts disabled", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + expect(wallet.apiVersion).toBe("1"); + expect(wallet.name).toBe("mesh-headless"); + expect(await wallet.isEnabled()).toBe(false); + }); + + it("enable() grants only the requested ∩ supported extensions", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }], + }); + const api = await wallet.enable({ + extensions: [{ cip: 95 }, { cip: 999 }], + }); + expect(await api.getExtensions()).toEqual([{ cip: 95 }]); + expect(await wallet.isEnabled()).toBe(true); + }); + + it("enable() grants nothing when no extensions are requested", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }], + }); + const api = await wallet.enable(); + expect(await api.getExtensions()).toEqual([]); + }); + + it("throws Refused and stays disabled when autoApprove is false", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + autoApprove: false, + }); + await expect(wallet.enable()).rejects.toMatchObject({ + code: APIErrorCode.Refused, + }); + expect(await wallet.isEnabled()).toBe(false); + }); + + it("throws Refused when the approve callback declines", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + approve: async () => false, + }); + await expect(wallet.enable()).rejects.toMatchObject({ + code: APIErrorCode.Refused, + }); + }); +}); diff --git a/test/cardano/cip30/conformance-endpoints.test.ts b/test/cardano/cip30/conformance-endpoints.test.ts new file mode 100644 index 0000000..d2c9fc3 --- /dev/null +++ b/test/cardano/cip30/conformance-endpoints.test.ts @@ -0,0 +1,355 @@ +/** + * CIP-30 conformance: the data-retrieval and tx-submission endpoints of the + * negotiated API (everything except signTx/signData, covered separately in + * conformance-signing.test.ts). + * + * @see https://cips.cardano.org/cips/cip30/#dataschema + */ +import { Cardano, Serialization } from "@cardano-sdk/core"; + +import { ISubmitter } from "@meshsdk/common"; +import { OfflineFetcher } from "@meshsdk/provider"; + +import { AddressType } from "../../../src/cardano/address/cardano-address"; +import { createCip30Api } from "../../../src/cardano/cip30/cip30-api"; +import { + APIErrorCode, + Cip30PaginateError, + TxSendErrorCode, +} from "../../../src/cardano/cip30/errors"; +import { mergeValue } from "../../../src/cardano/utils/value"; +import { CardanoHeadlessWallet } from "../../../src/cardano/wallet/mesh/cardano-headless-wallet"; +import { + coinCbor, + createWallet, + FIXTURE_ASSET_ID, + FIXTURE_ASSET_QUANTITY, + MNEMONIC, + TOTAL_FIXTURE_COIN, +} from "./fixtures"; + +function coinOf(utxoHex: string): bigint { + return Serialization.TransactionUnspentOutput.fromCbor(utxoHex as any) + .output() + .amount() + .coin(); +} + +describe("CIP-30 api.getNetworkId()", () => { + it("returns the wallet's network ID (0 for this testnet fixture)", async () => { + const api = createCip30Api(await createWallet()); + await expect(api.getNetworkId()).resolves.toBe(0); + }); +}); + +describe("CIP-30 api.getUtxos(amount, paginate)", () => { + it("with no args, returns all utxos, each a valid CBOR-round-tripping TransactionUnspentOutput", async () => { + const api = createCip30Api(await createWallet()); + const utxos = await api.getUtxos(); + expect(utxos).toHaveLength(6); + for (const hex of utxos!) { + expect( + Serialization.TransactionUnspentOutput.fromCbor(hex as any).toCbor(), + ).toBe(hex); + } + }); + + it("with a satisfiable amount, the returned subset's merged value covers it", async () => { + const api = createCip30Api(await createWallet()); + const target = new Serialization.Value(1_000_000_000n); + const utxos = await api.getUtxos(target.toCbor()); + expect(utxos).not.toBeNull(); + const total = utxos!.reduce( + (sum, hex) => sum + coinOf(hex), + 0n, + ); + expect(total).toBeGreaterThanOrEqual(1_000_000_000n); + }); + + it("returns null when the wallet lacks the requested native asset entirely", async () => { + const api = createCip30Api(await createWallet()); + const missingAsset = Cardano.AssetId( + "ff".repeat(28) + Buffer.from("nonexistent").toString("hex"), + ); + const target = new Serialization.Value( + 0n, + new Map([[missingAsset, 1n]]), + ); + await expect(api.getUtxos(target.toCbor())).resolves.toBeNull(); + }); + + it("returns null when the amount exceeds total ADA across all utxos", async () => { + const api = createCip30Api(await createWallet()); + const target = new Serialization.Value(TOTAL_FIXTURE_COIN + 1n); + await expect(api.getUtxos(target.toCbor())).resolves.toBeNull(); + }); + + it("paginate slices exact items for page 0 and page 1", async () => { + const api = createCip30Api(await createWallet()); + const page0 = await api.getUtxos(undefined, { page: 0, limit: 2 }); + expect(page0!.map(coinOf)).toEqual([977313882n, 977313882n]); + + const page1 = await api.getUtxos(undefined, { page: 1, limit: 2 }); + expect(page1!.map(coinOf)).toEqual([954457687n, 954284486n]); + }); + + it("throws Cip30PaginateError with maxSize === total when the page is out of range", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.getUtxos(undefined, { page: 10, limit: 2 }), + ).rejects.toMatchObject({ maxSize: 6 }); + }); + + it("filters by amount, then paginates the filtered subset", async () => { + const api = createCip30Api(await createWallet()); + // Every utxo but the smallest (5_000_000) covers this on its own, so the + // filtered subset is a 1-item greedy selection; page 0 limit 1 returns it. + const target = new Serialization.Value(900_000_000n); + const page = await api.getUtxos(target.toCbor(), { page: 0, limit: 1 }); + expect(page).toHaveLength(1); + expect(coinOf(page![0]!)).toBeGreaterThanOrEqual(900_000_000n); + }); + + it("returns [] (not an error) for a zero-utxo wallet's page 0", async () => { + // Same mnemonic/address shape as the shared fixture wallet, but wired to + // a fresh fetcher with no utxos added for any of its addresses. + const emptyWallet = await CardanoHeadlessWallet.fromMnemonic({ + mnemonic: MNEMONIC, + networkId: 0, + walletAddressType: AddressType.Base, + fetcher: new OfflineFetcher("preprod"), + }); + const emptyApi = createCip30Api(emptyWallet); + await expect( + emptyApi.getUtxos(undefined, { page: 0, limit: 5 }), + ).resolves.toEqual([]); + }); +}); + +describe("CIP-30 api.getBalance()", () => { + it("returns a valid Value CBOR equal to the sum of getUtxos()'s values", async () => { + const api = createCip30Api(await createWallet()); + const balanceCbor = await api.getBalance(); + const balance = Serialization.Value.fromCbor(balanceCbor as any); + + const utxos = await api.getUtxos(); + let summed = new Serialization.Value(0n); + for (const hex of utxos!) { + summed = mergeValue( + summed, + Serialization.TransactionUnspentOutput.fromCbor( + hex as any, + ) + .output() + .amount(), + ); + } + + expect(balance.coin()).toBe(summed.coin()); + expect(balance.coin()).toBe(TOTAL_FIXTURE_COIN); + expect(balance.multiasset()?.get(FIXTURE_ASSET_ID)).toBe( + FIXTURE_ASSET_QUANTITY, + ); + }); +}); + +describe("CIP-30 api.getUsedAddresses(paginate)", () => { + it("returns valid address hex, parseable via Cardano.Address.fromBytes", async () => { + const api = createCip30Api(await createWallet()); + const used = await api.getUsedAddresses(); + for (const hex of used) { + expect(Cardano.Address.fromBytes(hex as any)).toBeDefined(); + } + }); + + it("paginates and throws Cip30PaginateError out of range", async () => { + const api = createCip30Api(await createWallet()); + const all = await api.getUsedAddresses(); + expect(all).toHaveLength(2); + + const page0 = await api.getUsedAddresses({ page: 0, limit: 1 }); + expect(page0).toEqual([all[0]]); + const page1 = await api.getUsedAddresses({ page: 1, limit: 1 }); + expect(page1).toEqual([all[1]]); + + await expect( + api.getUsedAddresses({ page: 5, limit: 1 }), + ).rejects.toMatchObject({ maxSize: 2 }); + }); + + it("the fetcher-derived used addresses are distinct", async () => { + const api = createCip30Api(await createWallet()); + const used = await api.getUsedAddresses(); + expect(new Set(used).size).toBe(used.length); + }); + + it("falls back to the single-address behavior when the fetcher is present but returns zero utxos", async () => { + // Fetcher configured, but with no utxos loaded for any wallet address — + // must fall back to the stateless single-address result, not [] or throw. + const emptyWallet = await CardanoHeadlessWallet.fromMnemonic({ + mnemonic: MNEMONIC, + networkId: 0, + walletAddressType: AddressType.Base, + fetcher: new OfflineFetcher("preprod"), + }); + const api = createCip30Api(emptyWallet); + const used = await api.getUsedAddresses(); + expect(used).toHaveLength(1); + expect(Cardano.Address.fromBytes(used[0] as any)).toBeDefined(); + }); +}); + +describe("CIP-30 api.getUnusedAddresses()", () => { + it("returns valid address hex", async () => { + const api = createCip30Api(await createWallet()); + const [address] = await api.getUnusedAddresses(); + expect(Cardano.Address.fromBytes(address as any)).toBeDefined(); + }); +}); + +describe("CIP-30 api.getChangeAddress()", () => { + it("returns valid address hex", async () => { + const api = createCip30Api(await createWallet()); + const address = await api.getChangeAddress(); + expect(Cardano.Address.fromBytes(address as any)).toBeDefined(); + }); +}); + +describe("CIP-30 api.getRewardAddresses()", () => { + it("returns valid address hex in stake-credential (reward) form", async () => { + const api = createCip30Api(await createWallet()); + const [address] = await api.getRewardAddresses(); + const parsed = Cardano.Address.fromBytes(address as any); + expect(parsed).toBeDefined(); + expect(parsed!.getProps().type).toBe(Cardano.AddressType.RewardKey); + }); +}); + +describe("CIP-30 api.getCollateral(params)", () => { + it("with no params, returns pure-ADA utxo(s) covering the default 5 ADA", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral(); + expect(collateral).not.toBeNull(); + const total = collateral!.reduce((sum, hex) => sum + coinOf(hex), 0n); + expect(total).toBeGreaterThanOrEqual(5_000_000n); + }); + + it("all returned collateral utxos are pure ADA (no multiasset) and valid CBOR", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral(); + for (const hex of collateral!) { + const utxo = Serialization.TransactionUnspentOutput.fromCbor( + hex as any, + ); + expect(utxo.output().amount().multiasset()).toBeUndefined(); + expect(utxo.toCbor()).toBe(hex); + } + }); + + it("honors a small requested amount with a minimal (single-utxo) set", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral({ + amount: coinCbor(1_000_000n), + }); + expect(collateral).toHaveLength(1); + expect(coinOf(collateral![0]!)).toBe(5_000_000n); + }); + + it("returns null when the requested amount exceeds available pure-ADA", async () => { + const api = createCip30Api(await createWallet()); + const collateral = await api.getCollateral({ + amount: coinCbor(10_000_000_000_000n), + }); + expect(collateral).toBeNull(); + }); + + it("rejects a negative CBOR amount as Cip30APIError(InvalidRequest)", async () => { + const api = createCip30Api(await createWallet()); + // "20" is CBOR for -1 (major type 1) — Coin is non-negative per spec. + await expect(api.getCollateral({ amount: "20" })).rejects.toMatchObject({ + code: APIErrorCode.InvalidRequest, + }); + }); + + it("rejects non-integer CBOR amounts as Cip30APIError(InvalidRequest)", async () => { + const api = createCip30Api(await createWallet()); + // "80" is CBOR for an empty array — not a Coin. + await expect(api.getCollateral({ amount: "80" })).rejects.toMatchObject({ + code: APIErrorCode.InvalidRequest, + }); + }); +}); + +describe("CIP-30 api.submitTx(tx)", () => { + it("passes through the txid from a mock ISubmitter", async () => { + const submitter: ISubmitter = { + submitTx: async () => "mock-txid", + }; + const wallet = await createWallet(); + Object.assign(wallet, { submitter }); + const api = createCip30Api(wallet); + await expect(api.submitTx("84a0")).resolves.toBe("mock-txid"); + }); + + it("wraps a throwing submitter as Cip30TxSendError(Failure)", async () => { + const submitter: ISubmitter = { + submitTx: async () => { + throw new Error("node rejected the transaction"); + }, + }; + const wallet = await createWallet(); + Object.assign(wallet, { submitter }); + const api = createCip30Api(wallet); + await expect(api.submitTx("84a0")).rejects.toMatchObject({ + code: TxSendErrorCode.Failure, + }); + }); + + it("no submitter configured maps to Cip30APIError(InternalError)", async () => { + const api = createCip30Api(await createWallet(false)); + await expect(api.submitTx("84a0")).rejects.toMatchObject({ + code: APIErrorCode.InternalError, + }); + }); +}); + +describe("CIP-30 error wire shapes", () => { + it("Cip30APIError serializes to { code, info }", async () => { + const api = createCip30Api(await createWallet(false)); + try { + await api.getUtxos(); + throw new Error("expected getUtxos() to reject"); + } catch (error) { + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: APIErrorCode.InternalError, + info: expect.any(String), + }); + } + }); + + it("Cip30PaginateError serializes to { maxSize }, with no code/info", async () => { + const api = createCip30Api(await createWallet()); + try { + await api.getUtxos(undefined, { page: 10, limit: 2 }); + throw new Error("expected getUtxos() to reject"); + } catch (error) { + expect(error).toBeInstanceOf(Cip30PaginateError); + const wire = JSON.parse(JSON.stringify(error)); + expect(wire).toEqual({ maxSize: 6 }); + expect(wire.code).toBeUndefined(); + } + }); + + it("invalid pagination input serializes as Cip30APIError(InvalidRequest)", async () => { + const api = createCip30Api(await createWallet()); + try { + await api.getUtxos(undefined, { page: -1, limit: 2 }); + throw new Error("expected getUtxos() to reject"); + } catch (error) { + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: APIErrorCode.InvalidRequest, + info: expect.any(String), + }); + } + }); +}); diff --git a/test/cardano/cip30/conformance-initial-api.test.ts b/test/cardano/cip30/conformance-initial-api.test.ts new file mode 100644 index 0000000..1087b3f --- /dev/null +++ b/test/cardano/cip30/conformance-initial-api.test.ts @@ -0,0 +1,215 @@ +/** + * CIP-30 conformance: the initial API (`window.cardano.`), i.e. + * everything exposed before `enable()` plus `enable()`'s own negotiation + * semantics. + * + * @see https://cips.cardano.org/cips/cip30/#dataschema + */ +import { APIErrorCode } from "../../../src/cardano/cip30/errors"; +import { createCip30Wallet } from "../../../src/cardano/cip30/cip30-api"; +import { createWallet } from "./fixtures"; + +describe("CIP-30 initial API (window.cardano.)", () => { + describe("apiVersion / name / icon / supportedExtensions", () => { + it("apiVersion is the string '1', not the number 1", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + expect(wallet.apiVersion).toBe("1"); + expect(typeof wallet.apiVersion).toBe("string"); + }); + + it("passes through name and icon", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + icon: "data:image/png;base64,AA==", + }); + expect(wallet.name).toBe("mesh-headless"); + expect(wallet.icon).toBe("data:image/png;base64,AA=="); + }); + + it("defaults icon to an empty string when omitted", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + expect(wallet.icon).toBe(""); + }); + + it("supportedExtensions defaults to [] and otherwise passes through as given", async () => { + const withDefault = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + expect(withDefault.supportedExtensions).toEqual([]); + + const withExtensions = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }, { cip: 30 }], + }); + expect(withExtensions.supportedExtensions).toEqual([ + { cip: 95 }, + { cip: 30 }, + ]); + }); + }); + + describe("isEnabled()", () => { + it("is false before enable() and true after", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + expect(await wallet.isEnabled()).toBe(false); + await wallet.enable(); + expect(await wallet.isEnabled()).toBe(true); + }); + + it("stays false when enable() is refused", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + autoApprove: false, + }); + await expect(wallet.enable()).rejects.toBeDefined(); + expect(await wallet.isEnabled()).toBe(false); + }); + }); + + describe("enable()", () => { + it("returns the full CIP-30 API surface", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + const api = await wallet.enable(); + + // Spot-check every endpoint the spec's `ICip30Api` requires exists. + for (const method of [ + "getExtensions", + "getNetworkId", + "getUtxos", + "getBalance", + "getUsedAddresses", + "getUnusedAddresses", + "getChangeAddress", + "getRewardAddresses", + "getCollateral", + "signTx", + "signData", + "submitTx", + ] as const) { + expect(typeof api[method]).toBe("function"); + } + }); + + it("negotiates requested ∩ supported, dropping unsupported extensions silently", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }], + }); + // Requests both a supported and an unsupported extension; the spec + // requires the unsupported one to be dropped, not rejected. + const api = await wallet.enable({ + extensions: [{ cip: 95 }, { cip: 999 }], + }); + await expect(api.getExtensions()).resolves.toEqual([{ cip: 95 }]); + }); + + it("grants nothing when the wallet supports no extensions", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + }); + const api = await wallet.enable({ extensions: [{ cip: 95 }] }); + await expect(api.getExtensions()).resolves.toEqual([]); + }); + + it("grants nothing when no extensions are requested, even if some are supported", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }], + }); + const api = await wallet.enable(); + await expect(api.getExtensions()).resolves.toEqual([]); + }); + + it("throws Refused(-3) when autoApprove is false", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + autoApprove: false, + }); + await expect(wallet.enable()).rejects.toMatchObject({ + code: APIErrorCode.Refused, + }); + }); + + it("throws Refused(-3) when the approve callback declines", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + approve: async () => false, + }); + await expect(wallet.enable()).rejects.toMatchObject({ + code: APIErrorCode.Refused, + }); + }); + + it("Refused(-3) serializes to the spec's { code, info } wire shape", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + autoApprove: false, + }); + try { + await wallet.enable(); + throw new Error("expected enable() to reject"); + } catch (error) { + const wire = JSON.parse(JSON.stringify(error)); + expect(wire).toEqual({ + code: APIErrorCode.Refused, + info: expect.any(String), + }); + } + }); + }); + + describe("getExtensions()", () => { + it("returns the extensions granted during enable()", async () => { + const wallet = createCip30Wallet({ + wallet: await createWallet(), + name: "mesh-headless", + supportedExtensions: [{ cip: 95 }], + }); + const api = await wallet.enable({ extensions: [{ cip: 95 }] }); + await expect(api.getExtensions()).resolves.toEqual([{ cip: 95 }]); + }); + + it("independent createCip30Wallet instances don't share granted-extension state", async () => { + const walletA = createCip30Wallet({ + wallet: await createWallet(), + name: "wallet-a", + supportedExtensions: [{ cip: 95 }], + }); + const walletB = createCip30Wallet({ + wallet: await createWallet(), + name: "wallet-b", + supportedExtensions: [{ cip: 95 }], + }); + + const apiA = await walletA.enable({ extensions: [{ cip: 95 }] }); + const apiB = await walletB.enable(); + + await expect(apiA.getExtensions()).resolves.toEqual([{ cip: 95 }]); + await expect(apiB.getExtensions()).resolves.toEqual([]); + // Re-checking A confirms B's enable() didn't clobber A's grant. + await expect(apiA.getExtensions()).resolves.toEqual([{ cip: 95 }]); + }); + }); +}); diff --git a/test/cardano/cip30/conformance-signing.test.ts b/test/cardano/cip30/conformance-signing.test.ts new file mode 100644 index 0000000..7b4980a --- /dev/null +++ b/test/cardano/cip30/conformance-signing.test.ts @@ -0,0 +1,230 @@ +/** + * CIP-30 conformance: signTx and signData, both verified with real + * cryptography (not just shape checks). + * + * @see https://cips.cardano.org/cips/cip30/#dataschema + */ +import * as Crypto from "@cardano-sdk/crypto"; +import { Cardano, Serialization } from "@cardano-sdk/core"; +import { HexBlob } from "@cardano-sdk/util"; + +import { + CoseSign1, + getPublicKeyFromCoseKey, +} from "../../../src/cardano/signer/cip-8"; +import { createCip30Api } from "../../../src/cardano/cip30/cip30-api"; +import { + APIErrorCode, + DataSignErrorCode, + TxSignErrorCode, +} from "../../../src/cardano/cip30/errors"; +import { + buildForeignSignerTx, + createWallet, + decodeCoseKey, + decodeCoseSign1Protected, + ensureSodiumReady, + PAYMENT_CREDENTIAL_HASH, + SCRIPT_ADDRESS_BECH32, + SCRIPT_REWARD_ADDRESS_BECH32, + SIGN_TX_FIXTURES, + STAKE_CREDENTIAL_HASH, +} from "./fixtures"; + +beforeAll(ensureSodiumReady); + +describe("CIP-30 api.signTx(tx, partialSign)", () => { + it.each(SIGN_TX_FIXTURES.map((f, i) => [i, f] as const))( + "fixture %i: witness set parses and vkey pubkey hashes are a subset of the wallet's credentials", + async (_i, fixture) => { + const api = createCip30Api(await createWallet()); + const witnessSetCbor = await api.signTx(fixture.tx, false); + const witnessSet = Serialization.TransactionWitnessSet.fromCbor( + witnessSetCbor as any, + ); + const vkeys = Array.from(witnessSet.vkeys()!.values()); + expect(vkeys.length).toBe(fixture.signerHashes.length); + + const knownHashes = new Set([ + PAYMENT_CREDENTIAL_HASH, + STAKE_CREDENTIAL_HASH, + ]); + for (const vkey of vkeys) { + const hash = Crypto.Ed25519PublicKey.fromHex(vkey.vkey()) + .hash() + .hex(); + expect(knownHashes.has(hash)).toBe(true); + } + }, + ); + + it.each(SIGN_TX_FIXTURES.map((f, i) => [i, f] as const))( + "fixture %i: each vkey signature cryptographically verifies against the tx body hash", + async (_i, fixture) => { + const api = createCip30Api(await createWallet()); + const witnessSetCbor = await api.signTx(fixture.tx, false); + const witnessSet = Serialization.TransactionWitnessSet.fromCbor( + witnessSetCbor as any, + ); + + const bodyHash = Serialization.Transaction.fromCbor( + Serialization.TxCBOR(fixture.tx), + ) + .body() + .hash(); + + for (const vkey of Array.from(witnessSet.vkeys()!.values())) { + const publicKey = Crypto.Ed25519PublicKey.fromHex(vkey.vkey()); + const signature = Crypto.Ed25519Signature.fromHex(vkey.signature()); + expect(publicKey.verify(signature, HexBlob(bodyHash.toString()))).toBe( + true, + ); + } + }, + ); + + it("known-good fixtures match the wallet's own signature byte-for-byte", async () => { + const api = createCip30Api(await createWallet()); + for (const fixture of SIGN_TX_FIXTURES) { + await expect(api.signTx(fixture.tx, false)).resolves.toBe( + fixture.witnessSet, + ); + } + }); + + it("partialSign=false rejects with Cip30TxSignError(ProofGeneration) when a foreign signer is required", async () => { + const api = createCip30Api(await createWallet()); + const tx = buildForeignSignerTx(); + await expect(api.signTx(tx, false)).rejects.toMatchObject({ + code: TxSignErrorCode.ProofGeneration, + }); + }); + + it("partialSign=true succeeds, signing only with the resolvable signer", async () => { + const api = createCip30Api(await createWallet()); + const tx = buildForeignSignerTx(); + const witnessSetCbor = await api.signTx(tx, true); + const witnessSet = Serialization.TransactionWitnessSet.fromCbor( + witnessSetCbor as any, + ); + const vkeys = Array.from(witnessSet.vkeys()!.values()); + expect(vkeys).toHaveLength(1); + expect( + Crypto.Ed25519PublicKey.fromHex(vkeys[0]!.vkey()).hash().hex(), + ).toBe(PAYMENT_CREDENTIAL_HASH); + }); + + it("Cip30TxSignError serializes to { code, info }", async () => { + const api = createCip30Api(await createWallet()); + const tx = buildForeignSignerTx(); + try { + await api.signTx(tx, false); + throw new Error("expected signTx() to reject"); + } catch (error) { + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: TxSignErrorCode.ProofGeneration, + info: expect.any(String), + }); + } + }); +}); + +describe("CIP-30 api.signData(addr, payload)", () => { + it("DataSignature.signature and .key both parse as CBOR", async () => { + const api = createCip30Api(await createWallet()); + const address = await api.getChangeAddress(); + const { signature, key } = await api.signData(address, "68656c6c6f"); + expect(() => CoseSign1.fromCbor(signature)).not.toThrow(); + expect(() => getPublicKeyFromCoseKey(key)).not.toThrow(); + }); + + it("COSE_Sign1 protected header has alg=-8 and the address bytes match the input address", async () => { + const api = createCip30Api(await createWallet()); + const address = await api.getChangeAddress(); + const { signature } = await api.signData(address, "68656c6c6f"); + const { alg, address: addressBytes } = + decodeCoseSign1Protected(signature); + expect(alg).toBe(-8n); + expect(Buffer.from(addressBytes).toString("hex")).toBe(address); + }); + + it("COSE_Key has kty=1 (OKP), alg=-8 (EdDSA), crv=6 (Ed25519), and a 32-byte x", async () => { + const api = createCip30Api(await createWallet()); + const address = await api.getChangeAddress(); + const { key } = await api.signData(address, "68656c6c6f"); + const { kty, alg, crv, x } = decodeCoseKey(key); + expect(kty).toBe(1n); + expect(alg).toBe(-8n); + expect(crv).toBe(6n); + expect(x).toHaveLength(32); + }); + + it("signature cryptographically verifies via CoseSign1.verifySignature", async () => { + const api = createCip30Api(await createWallet()); + const address = await api.getChangeAddress(); + const { signature, key } = await api.signData(address, "68656c6c6f"); + + const coseSign1 = CoseSign1.fromCbor(signature); + const publicKeyBuffer = getPublicKeyFromCoseKey(key); + expect(coseSign1.verifySignature({ publicKeyBuffer })).toBe(true); + }); + + it("hex and bech32 input of the same address produce verifiable signatures with the same key", async () => { + const api = createCip30Api(await createWallet()); + const hexAddress = await api.getChangeAddress(); + const bech32Address = Cardano.Address.fromBytes( + hexAddress as any, + )!.toBech32(); + + const fromHex = await api.signData(hexAddress, "68656c6c6f"); + const fromBech32 = await api.signData(bech32Address, "68656c6c6f"); + + expect(fromBech32.key).toBe(fromHex.key); + + const publicKeyBuffer = getPublicKeyFromCoseKey(fromHex.key); + expect( + CoseSign1.fromCbor(fromHex.signature).verifySignature({ + publicKeyBuffer, + }), + ).toBe(true); + expect( + CoseSign1.fromCbor(fromBech32.signature).verifySignature({ + publicKeyBuffer, + }), + ).toBe(true); + }); + + it("script address rejects with Cip30DataSignError(AddressNotPK)", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.signData(SCRIPT_ADDRESS_BECH32, "68656c6c6f"), + ).rejects.toMatchObject({ code: DataSignErrorCode.AddressNotPK }); + }); + + it("script reward address rejects with Cip30DataSignError(AddressNotPK)", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.signData(SCRIPT_REWARD_ADDRESS_BECH32, "68656c6c6f"), + ).rejects.toMatchObject({ code: DataSignErrorCode.AddressNotPK }); + }); + + it("malformed address input rejects with Cip30APIError(InvalidRequest)", async () => { + const api = createCip30Api(await createWallet()); + await expect( + api.signData("not-an-address", "68656c6c6f"), + ).rejects.toMatchObject({ code: APIErrorCode.InvalidRequest }); + }); + + it("Cip30DataSignError serializes to { code, info }", async () => { + const api = createCip30Api(await createWallet()); + try { + await api.signData(SCRIPT_ADDRESS_BECH32, "68656c6c6f"); + throw new Error("expected signData() to reject"); + } catch (error) { + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: DataSignErrorCode.AddressNotPK, + info: expect.any(String), + }); + } + }); +}); diff --git a/test/cardano/cip30/errors.test.ts b/test/cardano/cip30/errors.test.ts new file mode 100644 index 0000000..3db671a --- /dev/null +++ b/test/cardano/cip30/errors.test.ts @@ -0,0 +1,198 @@ +import { + APIErrorCode, + Cip30APIError, + Cip30DataSignError, + Cip30PaginateError, + Cip30TxSendError, + Cip30TxSignError, + DataSignErrorCode, + isApiError, + isDataSignError, + isPaginateError, + isTxSendError, + isTxSignError, + TxSendErrorCode, + TxSignErrorCode, +} from "../../../src/cardano/cip30/errors"; + +describe("CIP-30 error codes", () => { + it("APIErrorCode matches the spec's numeric codes", () => { + expect(APIErrorCode.InvalidRequest).toBe(-1); + expect(APIErrorCode.InternalError).toBe(-2); + expect(APIErrorCode.Refused).toBe(-3); + expect(APIErrorCode.AccountChange).toBe(-4); + }); + + it("TxSignErrorCode matches the spec's numeric codes", () => { + expect(TxSignErrorCode.ProofGeneration).toBe(1); + expect(TxSignErrorCode.UserDeclined).toBe(2); + }); + + it("TxSendErrorCode matches the spec's numeric codes", () => { + expect(TxSendErrorCode.Refused).toBe(1); + expect(TxSendErrorCode.Failure).toBe(2); + }); + + it("DataSignErrorCode matches the spec's numeric codes", () => { + expect(DataSignErrorCode.ProofGeneration).toBe(1); + expect(DataSignErrorCode.AddressNotPK).toBe(2); + expect(DataSignErrorCode.UserDeclined).toBe(3); + }); +}); + +describe("Cip30APIError", () => { + it("is an instanceof Error and Cip30APIError", () => { + const error = new Cip30APIError(APIErrorCode.Refused, "user refused"); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(Cip30APIError); + expect(error.name).toBe("Cip30APIError"); + }); + + it("carries code and info, and serializes to the spec's wire shape", () => { + const error = new Cip30APIError(APIErrorCode.InvalidRequest, "bad request"); + expect(error.code).toBe(APIErrorCode.InvalidRequest); + expect(error.info).toBe("bad request"); + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: APIErrorCode.InvalidRequest, + info: "bad request", + }); + }); +}); + +describe("Cip30TxSignError", () => { + it("is an instanceof Error and Cip30TxSignError", () => { + const error = new Cip30TxSignError( + TxSignErrorCode.UserDeclined, + "user declined", + ); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(Cip30TxSignError); + expect(error.name).toBe("Cip30TxSignError"); + }); + + it("carries code and info, and serializes to the spec's wire shape", () => { + const error = new Cip30TxSignError( + TxSignErrorCode.ProofGeneration, + "could not derive key", + ); + expect(error.code).toBe(TxSignErrorCode.ProofGeneration); + expect(error.info).toBe("could not derive key"); + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: TxSignErrorCode.ProofGeneration, + info: "could not derive key", + }); + }); +}); + +describe("Cip30TxSendError", () => { + it("is an instanceof Error and Cip30TxSendError", () => { + const error = new Cip30TxSendError(TxSendErrorCode.Failure, "node rejected tx"); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(Cip30TxSendError); + expect(error.name).toBe("Cip30TxSendError"); + }); + + it("carries code and info, and serializes to the spec's wire shape", () => { + const error = new Cip30TxSendError(TxSendErrorCode.Refused, "user refused"); + expect(error.code).toBe(TxSendErrorCode.Refused); + expect(error.info).toBe("user refused"); + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: TxSendErrorCode.Refused, + info: "user refused", + }); + }); +}); + +describe("Cip30DataSignError", () => { + it("is an instanceof Error and Cip30DataSignError", () => { + const error = new Cip30DataSignError( + DataSignErrorCode.AddressNotPK, + "address is not a payment key", + ); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(Cip30DataSignError); + expect(error.name).toBe("Cip30DataSignError"); + }); + + it("carries code and info, and serializes to the spec's wire shape", () => { + const error = new Cip30DataSignError( + DataSignErrorCode.UserDeclined, + "user declined", + ); + expect(error.code).toBe(DataSignErrorCode.UserDeclined); + expect(error.info).toBe("user declined"); + expect(JSON.parse(JSON.stringify(error))).toEqual({ + code: DataSignErrorCode.UserDeclined, + info: "user declined", + }); + }); +}); + +describe("Cip30PaginateError", () => { + it("is an instanceof Error and Cip30PaginateError", () => { + const error = new Cip30PaginateError(10); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(Cip30PaginateError); + expect(error.name).toBe("Cip30PaginateError"); + }); + + it("carries maxSize and serializes to the spec's { maxSize } wire shape, with no code/info", () => { + const error = new Cip30PaginateError(25); + expect(error.maxSize).toBe(25); + const wire = JSON.parse(JSON.stringify(error)); + expect(wire).toEqual({ maxSize: 25 }); + expect(wire.code).toBeUndefined(); + expect(wire.info).toBeUndefined(); + }); +}); + +describe("type guards", () => { + const apiError = new Cip30APIError(APIErrorCode.Refused, "refused"); + const txSignError = new Cip30TxSignError(TxSignErrorCode.UserDeclined, "declined"); + const txSendError = new Cip30TxSendError(TxSendErrorCode.Failure, "failed"); + const dataSignError = new Cip30DataSignError( + DataSignErrorCode.ProofGeneration, + "failed", + ); + const paginateError = new Cip30PaginateError(5); + const plainError = new Error("plain error"); + const notAnError = { code: -3, info: "looks like one but isn't" }; + + it("isApiError matches only Cip30APIError", () => { + expect(isApiError(apiError)).toBe(true); + expect(isApiError(txSignError)).toBe(false); + expect(isApiError(txSendError)).toBe(false); + expect(isApiError(dataSignError)).toBe(false); + expect(isApiError(paginateError)).toBe(false); + expect(isApiError(plainError)).toBe(false); + expect(isApiError(notAnError)).toBe(false); + }); + + it("isTxSignError matches only Cip30TxSignError", () => { + expect(isTxSignError(txSignError)).toBe(true); + expect(isTxSignError(apiError)).toBe(false); + expect(isTxSignError(plainError)).toBe(false); + expect(isTxSignError(notAnError)).toBe(false); + }); + + it("isTxSendError matches only Cip30TxSendError", () => { + expect(isTxSendError(txSendError)).toBe(true); + expect(isTxSendError(apiError)).toBe(false); + expect(isTxSendError(plainError)).toBe(false); + expect(isTxSendError(notAnError)).toBe(false); + }); + + it("isDataSignError matches only Cip30DataSignError", () => { + expect(isDataSignError(dataSignError)).toBe(true); + expect(isDataSignError(apiError)).toBe(false); + expect(isDataSignError(plainError)).toBe(false); + expect(isDataSignError(notAnError)).toBe(false); + }); + + it("isPaginateError matches only Cip30PaginateError", () => { + expect(isPaginateError(paginateError)).toBe(true); + expect(isPaginateError(apiError)).toBe(false); + expect(isPaginateError(plainError)).toBe(false); + expect(isPaginateError(notAnError)).toBe(false); + }); +}); diff --git a/test/cardano/cip30/fixtures.ts b/test/cardano/cip30/fixtures.ts new file mode 100644 index 0000000..e02882c --- /dev/null +++ b/test/cardano/cip30/fixtures.ts @@ -0,0 +1,324 @@ +/** + * Shared fixtures for the CIP-30 conformance suite (test/cardano/cip30/*). + * + * Single source of truth for: the "solution"x24 test mnemonic, its 6-UTxO + * `OfflineFetcher` fixture (also used by test/wallet/cardano-headless-wallet.test.ts + * and test/wallet/cip-30.test.ts, all deriving from the same mnemonic/base + * address), a wallet factory, and the known-good signTx/signData fixtures + * used for real cryptographic verification (not just shape checks). + */ +import { Cardano, Serialization } from "@cardano-sdk/core"; +import { Hash28ByteBase16 } from "@cardano-sdk/crypto"; +import { Cbor, CborNegInt, CborObj, CborUInt } from "@harmoniclabs/cbor"; + +import { UTxO } from "@meshsdk/common"; +import { OfflineFetcher } from "@meshsdk/provider"; + +import { AddressType } from "../../../src/cardano/address/cardano-address"; +import { CardanoHeadlessWallet } from "../../../src/cardano/wallet/mesh/cardano-headless-wallet"; + +export const MNEMONIC = "solution,".repeat(24).split(",").slice(0, 24); + +// 6 UTxOs across the wallet's base address (4 pure-ADA) and its +// enterprise-shaped payment-part address (1 with a native asset, 1 pure-ADA +// >= the 5 ADA collateral floor) - same set as +// test/wallet/cardano-headless-wallet.test.ts and test/cardano/cip30/cip30-api.test.ts, +// giving 2 distinct used addresses and a mix of pure-ADA/multiasset UTxOs to +// select over. Exported as a plain array (in addition to the pre-loaded +// `offlineFetcher` below) so provider-attachment.test.ts can build a second, +// independent IFetcher implementation from the exact same data. +export const FIXTURE_UTXOS: UTxO[] = [ + { + input: { + txHash: + "45703dfd724f8bc92ebabdbff28b54d3434b126f31d31b2fffa5e3ed1edc1023", + outputIndex: 1, + }, + output: { + address: + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9", + amount: [{ unit: "lovelace", quantity: "977313882" }], + }, + }, + { + input: { + txHash: + "e6a99b6338fbacd1e411c7bf69d963d83975d8ad1336cb70cd600bdd049c4cae", + outputIndex: 1, + }, + output: { + address: + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9", + amount: [{ unit: "lovelace", quantity: "977313882" }], + }, + }, + { + input: { + txHash: + "62e6bf27216633a367924fd9d94681f75609788fa8e6187c8a583a95d60fbbcd", + outputIndex: 1, + }, + output: { + address: + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9", + amount: [{ unit: "lovelace", quantity: "954457687" }], + }, + }, + { + input: { + txHash: + "ad3ec70ffbc9a2d169fc6a4a9fdbae168ebad547f3939c97fc3bb41fa70c9999", + outputIndex: 0, + }, + output: { + address: + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9", + amount: [{ unit: "lovelace", quantity: "954284486" }], + }, + }, + { + input: { + txHash: + "ad3ec70ffbc9a2d169fc6a4a9fdbae168ebad547f3939c97fc3bb41fa70c9999", + outputIndex: 1, + }, + output: { + address: + "addr_test1vpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0c7e4cxr", + amount: [ + { unit: "lovelace", quantity: "500000000" }, + { + unit: "0ba402c042775dfffedbd958cae3805a281bad34f46b5b6fd5c2c7714d657368546f6b656e", + quantity: "1", + }, + ], + }, + }, + { + input: { + txHash: + "ad3ec70ffbc9a2d169fc6a4a9fdbae168ebad547f3939c97fc3bb41fa70c9999", + outputIndex: 2, + }, + output: { + address: + "addr_test1vpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0c7e4cxr", + amount: [{ unit: "lovelace", quantity: "5000000" }], + }, + }, +]; + +export const offlineFetcher = new OfflineFetcher("preprod"); +offlineFetcher.addUTxOs(FIXTURE_UTXOS); + +/** Total lovelace + the single native asset quantity across all 6 fixture UTxOs. */ +export const TOTAL_FIXTURE_COIN = + 977313882n + 977313882n + 954457687n + 954284486n + 500000000n + 5000000n; +export const FIXTURE_ASSET_ID = Cardano.AssetId( + "0ba402c042775dfffedbd958cae3805a281bad34f46b5b6fd5c2c7714d657368546f6b656e", +); +export const FIXTURE_ASSET_QUANTITY = 1n; + +/** Encodes a plain CBOR unsigned int, as used by `getCollateral({ amount })`'s `cbor` shape. */ +export function coinCbor(lovelace: bigint): string { + return Buffer.from(Cbor.encode(new CborUInt(lovelace)).toBuffer()).toString( + "hex", + ); +} + +export async function createWallet( + withFetcher = true, +): Promise { + return CardanoHeadlessWallet.fromMnemonic({ + mnemonic: MNEMONIC, + networkId: 0, + walletAddressType: AddressType.Base, + fetcher: withFetcher ? offlineFetcher : undefined, + }); +} + +// The wallet's payment/stake credential hashes, embedded in its base address +// (addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9): +// header byte + 28-byte payment hash + 28-byte stake hash. +export const PAYMENT_CREDENTIAL_HASH = + "5867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f"; +export const STAKE_CREDENTIAL_HASH = + "9d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e6"; + +// Well-formed testnet enterprise address over an all-zero script hash +// (28 bytes), used to exercise the AddressNotPK signData path without +// needing a real script. +export const SCRIPT_ADDRESS_BECH32 = + "addr_test1wqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqydhgrt"; + +// A reward/stake address whose credential is a script hash rather than a key +// hash - the other AddressNotPK case per spec ("script or reward address +// without a key"). See conformance-signing.test.ts for the adapter bug this +// fixture exposes (isScriptAddress() reads the wrong Address field for +// reward addresses). +export const SCRIPT_REWARD_ADDRESS_BECH32 = Cardano.RewardAddress.fromCredentials( + 0, + { hash: "00".repeat(28), type: Cardano.CredentialType.ScriptHash }, +) + .toAddress() + .toBech32(); + +/** + * Three known-good tx CBOR -> witness-set CBOR pairs, reused verbatim from + * test/wallet/cardano-headless-wallet.test.ts's "should sign with correct + * witness" test. Fixture 1 requires only the payment signer; fixtures 2 and 3 + * additionally require the stake signer (a certificate and a withdrawal, + * respectively), so each carries 2 vkey witnesses. + */ +export const SIGN_TX_FIXTURES: { + tx: string; + witnessSet: string; + signerHashes: string[]; +}[] = [ + { + tx: "84a500d901028282582045703dfd724f8bc92ebabdbff28b54d3434b126f31d31b2fffa5e3ed1edc102301825820e6a99b6338fbacd1e411c7bf69d963d83975d8ad1336cb70cd600bdd049c4cae01018282583900fbbd5c9ecf59fb9ba10f723003d3a3ed6214fa71f03b85041ae5c2e34253771c046276f5eb6777961f972c7ef25abad3f3319ea69cad00e21a3b9aca00825839005867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f9d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e61a38e3de57021a0002985d031a063659930801a100d9010281825820c32dfdb461dd016e8fdd9b6d424a77439eab8f8c644a804b013b6cefa2454f9558402047ca362a5f5a9b2f9c5ad0c772c3be8eb1b66fe8e86faf96f5717fcc05b1340da85cbac4ba8d6182ac849a66792d70340a7ea3ff9bea76460d982ff5a46f0af5f6", + witnessSet: + "a100d9010281825820c32dfdb461dd016e8fdd9b6d424a77439eab8f8c644a804b013b6cefa2454f9558402047ca362a5f5a9b2f9c5ad0c772c3be8eb1b66fe8e86faf96f5717fcc05b1340da85cbac4ba8d6182ac849a66792d70340a7ea3ff9bea76460d982ff5a46f0a", + signerHashes: [PAYMENT_CREDENTIAL_HASH], + }, + { + tx: "84a600d901028182582062e6bf27216633a367924fd9d94681f75609788fa8e6187c8a583a95d60fbbcd010181825839005867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f9d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e61a38e139c6021a0002a491031a063d0f2d04d901028183098200581c9d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e68200581c9816db59cc3cefc2d41dd05fde9c04b4012afc74bb9d0072cae72f110801a100d9010282825820c32dfdb461dd016e8fdd9b6d424a77439eab8f8c644a804b013b6cefa2454f955840ae75a2cc6cabda10b30849164457bddcbf0248f6afa1153dc03a7a136b177f6764757691335e1670aba2e26b3cc7d7ee82bebf1cfe7b9f6b9ba352c984c7b50382582002c9f4600bc90fcf09c7ef26346fd64dc3f39c3695ed986f53caad400ef419ad58403c58828bd5a4bcf0e6de64bc5bf1459dd3afaad1f3135d0f2d4309f867ffdf8f944a5ca98d35981024459a2eec9d9da28bfcb8c08d67f34c68ed95bb1501940df5f6", + witnessSet: + "a100d9010282825820c32dfdb461dd016e8fdd9b6d424a77439eab8f8c644a804b013b6cefa2454f955840ae75a2cc6cabda10b30849164457bddcbf0248f6afa1153dc03a7a136b177f6764757691335e1670aba2e26b3cc7d7ee82bebf1cfe7b9f6b9ba352c984c7b50382582002c9f4600bc90fcf09c7ef26346fd64dc3f39c3695ed986f53caad400ef419ad58403c58828bd5a4bcf0e6de64bc5bf1459dd3afaad1f3135d0f2d4309f867ffdf8f944a5ca98d35981024459a2eec9d9da28bfcb8c08d67f34c68ed95bb1501940d", + signerHashes: [PAYMENT_CREDENTIAL_HASH, STAKE_CREDENTIAL_HASH], + }, + { + tx: "84a600d9010281825820ad3ec70ffbc9a2d169fc6a4a9fdbae168ebad547f3939c97fc3bb41fa70c9999000181825839005867c3b8e27840f556ac268b781578b14c5661fc63ee720dbeab663f9d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e61a75769bcf021a00029ee5031a063d111505a1581de09d4dcd7e454d2434164f4efb8edeb358d86a1dad9ec6224cfcbce3e61a3c9800ee0801a0f5f6", + witnessSet: + "a100d9010282825820c32dfdb461dd016e8fdd9b6d424a77439eab8f8c644a804b013b6cefa2454f955840b71e7ffac89eac0dc051323fe30172b3c83d753f92c489478188ccbeed67b77bcc1af7d42c43d987ac767eeb3b0aea20697dbf87697de00487d16cbe2ff7750e82582002c9f4600bc90fcf09c7ef26346fd64dc3f39c3695ed986f53caad400ef419ad5840f40a8b338f56e958e65051178c9397e4c15c522aca5a0ff5639c5ee6f9a040280e7c5b15981504937978311c8763aedc164a89535f9fdcbb28089294efdc6b0e", + signerHashes: [PAYMENT_CREDENTIAL_HASH, STAKE_CREDENTIAL_HASH], + }, +]; + +/** Credential hash injected via `requiredExtraSignatures` that the wallet can never resolve. */ +export const FOREIGN_CREDENTIAL_HASH = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8"; + +/** + * Builds a tx (spending the fixture's first UTxO) whose `requiredExtraSignatures` + * includes both a real wallet signer (the payment credential, via the input) + * and `FOREIGN_CREDENTIAL_HASH`, which no wallet can ever resolve. Used to + * exercise `signTx`'s `partialSign` semantics: `partialSign=false` must + * reject (not all required signers found), `partialSign=true` must succeed + * with only the resolvable signer's witness. + */ +export function buildForeignSignerTx(): string { + const coreTx = { + inputs: [ + { + txId: Cardano.TransactionId( + "45703dfd724f8bc92ebabdbff28b54d3434b126f31d31b2fffa5e3ed1edc1023", + ), + index: 1, + }, + ], + outputs: [ + { + address: Cardano.PaymentAddress( + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9", + ), + value: { coins: 900_000_000n }, + }, + ], + requiredExtraSignatures: [Hash28ByteBase16(FOREIGN_CREDENTIAL_HASH)], + fee: 200_000n, + }; + const body = Serialization.TransactionBody.fromCore(coreTx as any); + const witnessSet = new Serialization.TransactionWitnessSet(); + return new Serialization.Transaction(body, witnessSet).toCbor(); +} + +/** + * `Ed25519PublicKey.hash()`/`.verify()` (used throughout the signTx/signData + * conformance tests for real cryptographic assertions) call into + * libsodium-wrappers-sumo's WASM build, which initializes asynchronously. + * Call this from a `beforeAll` before using either method. + */ +export async function ensureSodiumReady(): Promise { + const sodium = await import("libsodium-wrappers-sumo"); + await sodium.ready; +} + +// -- COSE decoding helpers, for structural signData assertions ------------ +// +// `CardanoSigner.signData` (src/cardano/signer/cip-8.ts) builds its +// protected header and COSE_Key by hand with @harmoniclabs/cbor primitives; +// these helpers decode them back out the same way, independent of the +// `CoseSign1` class (which doesn't expose the protected map directly), so +// tests can assert on the exact wire fields the CIP-8 spec requires. + +function cborMapEntries(map: { + map: { k: CborObj; v: CborObj }[]; +}): Map { + const entries = new Map(); + for (const { k, v } of map.map) { + if (k instanceof CborUInt || k instanceof CborNegInt) { + entries.set(k.num, v); + } else if ("text" in k) { + entries.set(k.text, v); + } + } + return entries; +} + +/** Decodes a COSE_Sign1 signature's protected header map: `{ 1: alg, "address": bytes }`. */ +export function decodeCoseSign1Protected(signatureHex: string): { + alg: bigint; + address: Uint8Array; +} { + const array = Cbor.parse(signatureHex); + if (!("array" in array)) { + throw new Error("Expected a COSE_Sign1 CBOR array"); + } + const protectedBytes = array.array[0]; + if (!("bytes" in protectedBytes)) { + throw new Error("Expected protected header to be a CBOR bstr"); + } + const protectedMap = Cbor.parse(protectedBytes.bytes); + if (!("map" in protectedMap)) { + throw new Error("Expected protected header to decode to a CBOR map"); + } + const entries = cborMapEntries(protectedMap); + const alg = entries.get(1n); + const address = entries.get("address"); + if (!alg || !("num" in alg) || !address || !("bytes" in address)) { + throw new Error("Malformed COSE_Sign1 protected header"); + } + return { alg: alg.num, address: address.bytes }; +} + +/** Decodes a COSE_Key: `{ 1: kty, 3: alg, -1: crv, -2: x }`. */ +export function decodeCoseKey(keyHex: string): { + kty: bigint; + alg: bigint; + crv: bigint; + x: Uint8Array; +} { + const keyMap = Cbor.parse(keyHex); + if (!("map" in keyMap)) { + throw new Error("Expected a COSE_Key CBOR map"); + } + const entries = cborMapEntries(keyMap); + const kty = entries.get(1n); + const alg = entries.get(3n); + const crv = entries.get(-1n); + const x = entries.get(-2n); + if ( + !kty || + !("num" in kty) || + !alg || + !("num" in alg) || + !crv || + !("num" in crv) || + !x || + !("bytes" in x) + ) { + throw new Error("Malformed COSE_Key"); + } + return { kty: kty.num, alg: alg.num, crv: crv.num, x: x.bytes }; +} diff --git a/test/cardano/cip30/provider-attachment.test.ts b/test/cardano/cip30/provider-attachment.test.ts new file mode 100644 index 0000000..3cdc12f --- /dev/null +++ b/test/cardano/cip30/provider-attachment.test.ts @@ -0,0 +1,91 @@ +/** + * A3 evidence: the CIP-30 adapter is provider-agnostic. It's driven purely + * through `ICardanoWallet`'s `IFetcher`/`ISubmitter` dependencies, so any + * conforming implementation of those interfaces can be swapped in without + * touching the adapter or wallet code. + * + * Proven here with two independently-built `IFetcher`s - the SDK's + * `OfflineFetcher` and a ~10-line hand-rolled fetcher backed by a plain + * array - fed the *same* fixture data, plus an `ISubmitter` swap. + */ +import { AddressType } from "../../../src/cardano/address/cardano-address"; +import { createCip30Api } from "../../../src/cardano/cip30/cip30-api"; +import { CardanoHeadlessWallet } from "../../../src/cardano/wallet/mesh/cardano-headless-wallet"; + +import { IFetcher, ISubmitter, UTxO } from "@meshsdk/common"; + +import { FIXTURE_UTXOS, MNEMONIC, offlineFetcher } from "./fixtures"; + +/** + * Minimal hand-rolled `IFetcher`: implements only the one method the wallet + * actually calls (`fetchAddressUTxOs`), backed by a plain in-memory array + * instead of any SDK machinery - deliberately not `OfflineFetcher` under the + * hood, to prove the adapter has no implicit dependency on it. + */ +class ArrayFetcher implements Partial { + constructor(private readonly utxos: UTxO[]) {} + + async fetchAddressUTxOs(address: string): Promise { + return this.utxos.filter((utxo) => utxo.output.address === address); + } +} + +async function walletWithFetcher( + fetcher: IFetcher, +): Promise { + return CardanoHeadlessWallet.fromMnemonic({ + mnemonic: MNEMONIC, + networkId: 0, + walletAddressType: AddressType.Base, + fetcher, + }); +} + +describe("A3: provider swappability (IFetcher)", () => { + it("OfflineFetcher and a hand-rolled ArrayFetcher yield identical getUtxos() results given identical data", async () => { + const offlineApi = createCip30Api(await walletWithFetcher(offlineFetcher)); + const arrayApi = createCip30Api( + await walletWithFetcher(new ArrayFetcher(FIXTURE_UTXOS) as unknown as IFetcher), + ); + + const [offlineUtxos, arrayUtxos] = await Promise.all([ + offlineApi.getUtxos(), + arrayApi.getUtxos(), + ]); + + expect(arrayUtxos).toEqual(offlineUtxos); + expect(arrayUtxos).toHaveLength(6); + }); + + it("agree on getBalance() and getCollateral() too", async () => { + const offlineApi = createCip30Api(await walletWithFetcher(offlineFetcher)); + const arrayApi = createCip30Api( + await walletWithFetcher(new ArrayFetcher(FIXTURE_UTXOS) as unknown as IFetcher), + ); + + await expect(arrayApi.getBalance()).resolves.toBe( + await offlineApi.getBalance(), + ); + await expect(arrayApi.getCollateral()).resolves.toEqual( + await offlineApi.getCollateral(), + ); + }); +}); + +describe("A3: provider swappability (ISubmitter)", () => { + it("submitTx() passes through whichever ISubmitter the wallet is constructed with", async () => { + const submitterA: ISubmitter = { submitTx: async () => "txid-from-a" }; + const submitterB: ISubmitter = { submitTx: async () => "txid-from-b" }; + + const walletA = await walletWithFetcher(offlineFetcher); + Object.assign(walletA, { submitter: submitterA }); + const walletB = await walletWithFetcher(offlineFetcher); + Object.assign(walletB, { submitter: submitterB }); + + const apiA = createCip30Api(walletA); + const apiB = createCip30Api(walletB); + + await expect(apiA.submitTx("84a0")).resolves.toBe("txid-from-a"); + await expect(apiB.submitTx("84a0")).resolves.toBe("txid-from-b"); + }); +}); From 0c1e20f20bd0d610a1bba10f42fb95b4a0f68c07 Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 16:55:06 +0800 Subject: [PATCH 5/6] docs: CIP-30 conformance matrix and simulated-API usage - Conformance matrix (initial + full API) with documented deviations: stateless single-address semantics, auto-approve enable(), spec- deprecated getCollateral, unreachable decline/refusal codes - New 'Simulated CIP-30 API (headless)' section with usage examples and provider-attachment (A3) notes Co-Authored-By: Claude Fable 5 --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a290f9e..8bb2b60 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ npm install @meshsdk/wallet - [Architecture Overview](#architecture-overview) - [Exported Classes](#exported-classes) - [Headless Wallet (Server-Side)](#headless-wallet-server-side) +- [Simulated CIP-30 API (headless)](#simulated-cip-30-api-headless) - [Browser Wallet (Client-Side)](#browser-wallet-client-side) - [Low-Level Components](#low-level-components) - [CIP-30 Compatibility](#cip-30-compatibility) @@ -48,6 +49,10 @@ This package uses a two-tier class hierarchy for both headless and browser walle | `BaseSigner` | Ed25519 signer from raw private keys | Signing with raw private keys (normal or extended) | | `CardanoAddress` | Cardano address construction and utilities | Building addresses from credentials | | `ICardanoWallet` | Interface definition for Cardano wallets | Type-checking and implementing custom wallets | +| `createCip30Wallet` | Wraps an `ICardanoWallet` as a simulated CIP-30 initial API (`window.cardano.`-shaped), with `enable()` extension negotiation | dApp integration tests / server-side agents that need a real `window.cardano`-shaped API, no browser | +| `createCip30Api` | Wraps an `ICardanoWallet` as a spec-exact `ICip30Api` directly, without extension negotiation | Advanced: you already have your own `enable()` gating and just need the spec-exact endpoints | +| `ICip30Api` / `ICip30InitialApi` | Interface definitions for the CIP-30 API surface | Type-checking and implementing custom CIP-30 adapters | +| `Cip30APIError`, `Cip30PaginateError`, `Cip30TxSignError`, `Cip30DataSignError`, `Cip30TxSendError` | CIP-30 error classes (spec-exact wire shapes: `{code, info}` or `{maxSize}`) | Catching and pattern-matching on typed CIP-30 errors instead of plain `Error` | --- @@ -134,6 +139,66 @@ const signedTx = CardanoSigner.signTx(txHex, [paymentSigner], true); --- +## Simulated CIP-30 API (headless) + +`createCip30Wallet()` wraps any `ICardanoWallet` (e.g. `MeshCardanoHeadlessWallet`) as a full simulated CIP-30 API — the same shape a dApp sees at `window.cardano.` in a browser, but driven by a real signing wallet with zero browser and zero extension. This is the use case for dApp integration tests or server-side agents that need to exercise a genuine CIP-30 flow (`enable()` → `getUtxos()` → `signTx()` → ...) without a browser wallet extension in the loop. + +```typescript +import { MeshCardanoHeadlessWallet, AddressType, createCip30Wallet } from "@meshsdk/wallet"; +import { OfflineFetcher } from "@meshsdk/provider"; + +const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({ + mnemonic: "globe cupboard camera ...".split(" "), + networkId: 0, + walletAddressType: AddressType.Base, + fetcher: new OfflineFetcher("preprod"), +}); + +// Wrap the wallet as a full simulated CIP-30 wallet — exactly what a dApp +// sees from window.cardano.. +const cip30 = createCip30Wallet({ wallet, name: "mesh-headless" }); + +await cip30.isEnabled(); // false, until enable() is called +const api = await cip30.enable(); // negotiates extensions, auto-approves by default + +// Spec-exact endpoints: amount filtering, pagination, null semantics. +const utxos = await api.getUtxos(amountCbor, { page: 0, limit: 10 }); // null if amount unmet +const collateral = await api.getCollateral({ amount: coinCbor }); + +try { + await api.signTx(txCbor); +} catch (error) { + // error is a Cip30TxSignError: { code: 1 | 2, info: string } — not a bare Error + console.error(error.code, error.info); +} +``` + +`enable()` auto-approves by default, since a headless wallet has no UI to prompt with. Pass `autoApprove: false` or a custom `approve` hook to exercise the `Refused(-3)` path (e.g. in tests). + +### Provider attachment (A3) + +Because `createCip30Wallet` / `createCip30Api` only depend on the wallet's `ICardanoWallet` interface, swapping the underlying `IFetcher` (or `ISubmitter`) is a one-line change — nothing in the adapter or wallet code needs to know which provider it's talking to: + +```typescript +import { BlockfrostProvider } from "@meshsdk/provider"; + +const provider = new BlockfrostProvider(""); + +const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({ + mnemonic: "globe cupboard camera ...".split(" "), + networkId: 0, + walletAddressType: AddressType.Base, + fetcher: provider, // swapped from OfflineFetcher — same code, real network + submitter: provider, +}); + +const cip30 = createCip30Wallet({ wallet, name: "mesh-headless" }); +``` + +See [`test/cardano/cip30/provider-attachment.test.ts`](./test/cardano/cip30/provider-attachment.test.ts) for the proof: two independently-built `IFetcher` implementations fed identical fixture data produce identical `getUtxos()` / `getBalance()` / `getCollateral()` results. + +--- + ## Browser Wallet (Client-Side) ### Enable a Browser Wallet @@ -191,9 +256,41 @@ Signs Cardano transactions given an array of `ISigner` instances. Can return eit ## CIP-30 Compatibility -Both `MeshCardanoHeadlessWallet` and `MeshCardanoBrowserWallet` provide CIP-30 compatible methods: `getBalance`, `getChangeAddress`, `getNetworkId`, `getCollateral`, `getUtxos`, `getRewardAddresses`, `signTx`, `signData`, `submitTx`. - -**Important caveat for headless wallets:** The headless wallet simulates CIP-30 using a data provider (e.g., Blockfrost). It does not perform key derivation across multiple indices — it only derives keys at index 0 on all derivation paths (payment, stake, DRep). This means `getBalance` or `getUtxos` may return different results than a real browser wallet using the same mnemonic, since real wallets index multiple key derivations. +Both `MeshCardanoHeadlessWallet` and `MeshCardanoBrowserWallet` provide CIP-30 compatible methods: `getBalance`, `getChangeAddress`, `getNetworkId`, `getCollateral`, `getUtxos`, `getRewardAddresses`, `signTx`, `signData`, `submitTx`. For the headless side, `createCip30Wallet()` (see [Simulated CIP-30 API (headless)](#simulated-cip-30-api-headless)) wraps these into a spec-exact adapter — params, null semantics, and typed errors matching [CIP-30](https://cips.cardano.org/cips/cip30/) exactly. + +**Important caveat for headless wallets:** The headless wallet simulates CIP-30 using a data provider (e.g., Blockfrost). It does not perform key derivation across multiple indices — it only derives keys at index 0 on all derivation paths (payment, stake, DRep). This means `getBalance` or `getUtxos` may return different results than a real browser wallet using the same mnemonic, since real wallets index multiple key derivations. This is the "stateless single-address wallet" caveat referenced throughout the conformance matrix below. + +### Conformance matrix + +Endpoints implemented by the `createCip30Wallet()` / `createCip30Api()` adapter (`src/cardano/cip30/`), checked against the CIP-30 spec: + +**Initial API** (`window.cardano.`, before `enable()`): + +| Endpoint | Status | Notes | +|---|---|---| +| `apiVersion` | ✅ Conformant (adapter) | Always `"1"` | +| `name` | ✅ Conformant (adapter) | Set via `createCip30Wallet({ name })` | +| `icon` | ✅ Conformant (adapter) | Data URI or URL; defaults to `""` | +| `supportedExtensions` | ✅ Conformant (adapter) | Defaults to `[]` — only lists extensions *beyond* the base CIP-30 API (e.g. CIP-95) | +| `isEnabled()` | ✅ Conformant (adapter) | Reflects whether `enable()` has previously succeeded | +| `enable(extensions?)` | ✅ Conformant (adapter) | Extension negotiation (requested ∩ supported → granted). Auto-approves by default — a headless wallet has no UI to prompt with — configurable via `autoApprove`/`approve` to exercise `Refused(-3)` | + +**Full API** (returned by `enable()`): + +| Endpoint | Status | Notes | +|---|---|---| +| `getExtensions()` | ✅ Conformant (adapter) | Returns the extensions granted during `enable()` | +| `getNetworkId()` | ✅ Conformant (adapter) | | +| `getUtxos(amount?, paginate?)` | ✅ Conformant (adapter) | `amount` is `cbor`; UTxOs are selected until the merged value covers it, `null` if unmet. `paginate` throws `PaginateError{maxSize}` past the last page | +| `getBalance()` | ✅ Conformant (adapter) | | +| `getUsedAddresses(paginate?)` | ✅ Conformant (adapter)* | *Stateless single-address wallet — see caveat above. Paginated with `PaginateError{maxSize}` | +| `getUnusedAddresses()` | ✅ Conformant (adapter)* | *Same single-address caveat: the wallet is stateless and cannot track usage, so this always returns the one derived address (never empty) — a deviation from real used/unused semantics | +| `getChangeAddress()` | ✅ Conformant (adapter) | | +| `getRewardAddresses()` | ✅ Conformant (adapter) | | +| `getCollateral(params?)` | ✅ Conformant (adapter) | `params.amount` is `cbor`, defaults to 5 ADA. Pure-ADA UTxOs only, `null` if unmet. Deprecated in the CIP-30 spec (superseded by CIP-40) but implemented since wallets still call it | +| `signTx(tx, partialSign?)` | ✅ Conformant (adapter) | Throws `TxSignError(ProofGeneration = 1)` when signers can't be resolved. `UserDeclined(2)` is unreachable — the headless wallet has no decline hook for signing (only `enable()` does) | +| `signData(addr, payload)` | ✅ Conformant (adapter) | Accepts bech32 **or** hex address; throws `DataSignError(ProofGeneration = 1)` / `AddressNotPK(2)`. `UserDeclined(3)` is unreachable — no decline hook for signing | +| `submitTx(tx)` | ✅ Conformant (adapter) | Throws `TxSendError(2)` on node rejection. `Refused(1)` is unreachable — `ISubmitter` has no refusal signal, only success or throw | --- From b1e7dea86d5049c99588872ee8e2408e378938ca Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 19:23:49 +0800 Subject: [PATCH 6/6] docs: fix stale InMemoryBip32 references (class is CardanoInMemoryBip32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README documented an 'InMemoryBip32' export that doesn't exist — the class was renamed CardanoInMemoryBip32 when the Bitcoin SDK landed its BitcoinInMemoryBip32 counterpart. Import example would not compile. Co-Authored-By: Claude Fable 5 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8bb2b60..c849847 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This package uses a two-tier class hierarchy for both headless and browser walle | `CardanoHeadlessWallet` | CIP-30 strict headless wallet (raw hex/CBOR returns) | You need raw CIP-30 output without conversion | | `MeshCardanoBrowserWallet` | Full-featured browser wallet wrapper with convenience methods | dApp frontend integration with browser wallets (Eternl, Nami, etc.) | | `CardanoBrowserWallet` | CIP-30 strict browser wallet wrapper (raw hex/CBOR returns) | You need raw CIP-30 passthrough from browser wallets | -| `InMemoryBip32` | BIP32 key derivation from mnemonic (keys stored in memory) | Deriving payment/stake/DRep keys from a mnemonic | +| `CardanoInMemoryBip32` | BIP32 key derivation from mnemonic (keys stored in memory) | Deriving payment/stake/DRep keys from a mnemonic | | `BaseSigner` | Ed25519 signer from raw private keys | Signing with raw private keys (normal or extended) | | `CardanoAddress` | Cardano address construction and utilities | Building addresses from credentials | | `ICardanoWallet` | Interface definition for Cardano wallets | Type-checking and implementing custom wallets | @@ -104,13 +104,13 @@ const witnessSet = await wallet.signTx(unsignedTxHex); ### Custom Derivation Paths -Use `InMemoryBip32` directly for custom key derivation: +Use `CardanoInMemoryBip32` directly for custom key derivation: ```typescript -import { InMemoryBip32 } from "@meshsdk/wallet"; +import { CardanoInMemoryBip32 } from "@meshsdk/wallet"; const HARDENED_OFFSET = 0x80000000; -const bip32 = await InMemoryBip32.fromMnemonic( +const bip32 = await CardanoInMemoryBip32.fromMnemonic( "globe cupboard camera ...".split(" ") ); @@ -237,7 +237,7 @@ const signature = await wallet.signData(addressBech32, hexPayload); ## Low-Level Components -### InMemoryBip32 +### CardanoInMemoryBip32 Derives Ed25519 signing keys from a BIP39 mnemonic. Keys are held in memory. You can implement your own `Bip32` class (e.g., HSM-backed) as long as it satisfies the same interface.