From 02f5442f565dbe686c7864866d5158371b2a907b Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:18:17 -0600 Subject: [PATCH 1/4] Run STRK20 private transfers inside the vault SNIP-44 viewing key derived and kept in the vault, the privacy SDK driven there, private operations relayed through the paymaster with policy confirmation, and CavosStarknet.privacy on the page. Moves starknet to ^10.5.0 for proof-carrying transactions. --- .npmrc | 1 + jest.config.js | 4 +- jest.privacy-testing.js | 8 + package-lock.json | 1125 ++++++++++++++++++- package.json | 9 +- src/chains/starknet/CavosStarknet.ts | 35 +- src/chains/starknet/privacy/chain.ts | 97 ++ src/chains/starknet/privacy/config.test.ts | 62 + src/chains/starknet/privacy/config.ts | 138 +++ src/chains/starknet/privacy/fixtures.ts | 168 +++ src/chains/starknet/privacy/index.ts | 4 + src/chains/starknet/privacy/relay.test.ts | 118 ++ src/chains/starknet/privacy/relay.ts | 235 ++++ src/chains/starknet/privacy/sdk.ts | 17 + src/chains/starknet/privacy/session.test.ts | 136 +++ src/chains/starknet/privacy/session.ts | 379 +++++++ src/chains/starknet/privacyConnect.test.ts | 110 ++ src/chains/starknet/viewingKey.test.ts | 56 + src/chains/starknet/viewingKey.ts | 76 ++ src/index.ts | 8 + src/starknet.ts | 12 + src/vault/VaultClient.ts | 22 +- src/vault/host.ts | 113 +- src/vault/parseStarknet.ts | 38 + src/vault/privacy.test.ts | 242 ++++ src/vault/protocol.ts | 21 + tsup.config.ts | 1 + 27 files changed, 3191 insertions(+), 44 deletions(-) create mode 100644 .npmrc create mode 100644 jest.privacy-testing.js create mode 100644 src/chains/starknet/privacy/chain.ts create mode 100644 src/chains/starknet/privacy/config.test.ts create mode 100644 src/chains/starknet/privacy/config.ts create mode 100644 src/chains/starknet/privacy/fixtures.ts create mode 100644 src/chains/starknet/privacy/index.ts create mode 100644 src/chains/starknet/privacy/relay.test.ts create mode 100644 src/chains/starknet/privacy/relay.ts create mode 100644 src/chains/starknet/privacy/sdk.ts create mode 100644 src/chains/starknet/privacy/session.test.ts create mode 100644 src/chains/starknet/privacy/session.ts create mode 100644 src/chains/starknet/privacyConnect.test.ts create mode 100644 src/chains/starknet/viewingKey.test.ts create mode 100644 src/chains/starknet/viewingKey.ts create mode 100644 src/vault/privacy.test.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..f21ef49 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@starkware-libs:registry=https://npm.pkg.github.com diff --git a/jest.config.js b/jest.config.js index 48f3b01..8875c58 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,8 @@ module.exports = { // is ESM and breaks under ts-jest. Map uuid to its CJS build so it resolves. moduleNameMapper: { "^uuid$": "/node_modules/uuid/dist/index.js", + "^@starkware-libs/starknet-privacy-sdk$": "/node_modules/@starkware-libs/starknet-privacy-sdk/dist/index.js", + "^@starkware-libs/starknet-privacy-sdk/testing$": "/jest.privacy-testing.js", }, // @stellar/stellar-sdk (even its CJS build) transitively imports @noble/hashes // v2, which ships ESM-only. Transform those packages instead of ignoring them. @@ -15,6 +17,6 @@ module.exports = { "^.+\\.[tj]sx?$": ["ts-jest", { isolatedModules: true, tsconfig: { allowJs: true } }], }, transformIgnorePatterns: [ - "node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras)/)", + "node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras|hpke|@starkware-libs/starknet-privacy-sdk)/)", ], }; diff --git a/jest.privacy-testing.js b/jest.privacy-testing.js new file mode 100644 index 0000000..d9e2119 --- /dev/null +++ b/jest.privacy-testing.js @@ -0,0 +1,8 @@ +const dist = "./node_modules/@starkware-libs/starknet-privacy-sdk/dist"; + +module.exports = { + ...require(`${dist}/testing/mocknet.js`), + ...require(`${dist}/testing/mock-proof-provider.js`), + ...require(`${dist}/testing/mock-proof-invocation-factory.js`), + ...require(`${dist}/internal/contract-discovery.js`), +}; diff --git a/package-lock.json b/package-lock.json index 668275a..f06bde8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,10 @@ "@stellar/stellar-sdk": "^16.0.1", "@turnkey/sdk-browser": "^6.1.1", "buffer": "^6.0.3", - "starknet": "^9.4.2" + "starknet": "^10.5.0" }, "devDependencies": { + "@starkware-libs/starknet-privacy-sdk": "^0.14.3-rc.5", "@types/jest": "^29.5.14", "@types/node": "^22.9.0", "@types/react": "^19.0.0", @@ -1898,6 +1899,62 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2859,11 +2916,150 @@ "ox": "^0.4.4" } }, - "node_modules/@starknet-io/starknet-types-010": { + "node_modules/@starknet-io/get-starknet-wallet-standard-v6": { + "name": "@starknet-io/get-starknet-wallet-standard", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-6.0.6.tgz", + "integrity": "sha512-iBS7zwBP5l7yKxM/1INOBzqB82KSgYqmLPrBlgNs/uYNJyCj+ZL7yTVuUyv1174Y33yztN5Lvwxppsiyxg1+Uw==", + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "0.10.4", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "ox": "^1.6.2" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/bip32": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.4.0.tgz", + "integrity": "sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/@starknet-io/types-js": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4.tgz", + "integrity": "sha512-uyrgECEHzKpQfUSXgFpM3vPov4qYSoFQzdUNW+UEPHRJtaemzZGWPvIjuerPgjmrxKsh6q8nqzEKdm6BFykRlQ==", + "license": "MIT" + }, + "node_modules/@starknet-io/get-starknet-wallet-standard-v6/node_modules/ox": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/ox/-/ox-1.8.8.tgz", + "integrity": "sha512-zPWwnOSwzWO6vmNPp02GYLbz5OwfQ0nAUyh5whRNyH4l65y9GAXFG3Jrcn5lZU3E2S0PBXIMnCgsm+P9Mj+10Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.6.1", + "@scure/bip32": "^2.0.0", + "@scure/bip39": "^2.0.0", + "abitype": "^1.3.0", + "eventemitter3": "5.0.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@starknet-io/starknet-types-0101": { "name": "@starknet-io/types-js", - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.0.tgz", - "integrity": "sha512-7ALSydz6pq3YIOpq5a7OkkxqwJciMc9Nlph0OGjhcC3xX0xH30XgizmziLyYVN10oO9+BJk8M9KbJjpzdbtRSw==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.2.tgz", + "integrity": "sha512-AtUFPYdmo9DqVus++aBSoY9W13/2PZmillPr8/mXZjc+V0iYJ/QTmkTsbw+es2mnLeLhYWSymW9ivQzyyyKdog==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0103": { + "name": "@starknet-io/types-js", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.3.tgz", + "integrity": "sha512-WtTGjqgyjqYSaSks/CQrpERGiLlwhr1TTD4llsr8IKEZHb78OJEmEhzrb/LxJV1SIz+MEsB1pioG62BOmFKYLA==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-0104": { + "name": "@starknet-io/types-js", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4.tgz", + "integrity": "sha512-uyrgECEHzKpQfUSXgFpM3vPov4qYSoFQzdUNW+UEPHRJtaemzZGWPvIjuerPgjmrxKsh6q8nqzEKdm6BFykRlQ==", "license": "MIT" }, "node_modules/@starknet-io/starknet-types-09": { @@ -2879,6 +3075,117 @@ "integrity": "sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==", "license": "MIT" }, + "node_modules/@starkware-libs/starknet-privacy-sdk": { + "version": "0.14.3-rc.5", + "resolved": "https://npm.pkg.github.com/download/@starkware-libs/starknet-privacy-sdk/0.14.3-rc.5/4d9cae8346aa6ea70f14b65a93d5f722e95e4a83", + "integrity": "sha512-sq/a+mq9ZN086inI7uPmy7JClHH7HYt5nk2ZgoTFpmvxUVy3H6snyJmwkACZSRdi1omKZ2mufHhe6kV4tnFP0g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@starknet-io/starknet-types-0101": "npm:@starknet-io/types-js@~0.10.2", + "ohttp-ts": "^0.3.0", + "starknet": "10.5.0", + "starknet-devnet": "^0.7.2", + "zod": "^3.24.0" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/@noble/curves": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/@noble/hashes": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz", + "integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/@starknet-io/get-starknet-wallet-standard-v6": { + "name": "@starknet-io/get-starknet-wallet-standard", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@starknet-io/get-starknet-wallet-standard/-/get-starknet-wallet-standard-6.0.2.tgz", + "integrity": "sha512-jCnLFNPw4IGgmkwDlHszKbnyEbQKu3pE7cZHyVMFwyvRG98IA9FZKykrTNH7Nq3BILf6EcOZ5SgYUQE/gtqraA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@starknet-io/types-js": "^0.10.3", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "ox": "^0.4.4" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/@starknet-io/types-js": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.10.4.tgz", + "integrity": "sha512-uyrgECEHzKpQfUSXgFpM3vPov4qYSoFQzdUNW+UEPHRJtaemzZGWPvIjuerPgjmrxKsh6q8nqzEKdm6BFykRlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/starknet": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/starknet/-/starknet-10.5.0.tgz", + "integrity": "sha512-hHKW39sVFLCGPC8QKdQksbs8qo10Rjb6Zdpv3nKMYQUeXDtffvI1SOa62aX8+WcnpfG7Te4UBx5NTomz/X7ZpA==", + "deprecated": "Superseded. Upgrade to starknet@10.8.0 or later.", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0", + "@scure/base": "~1.2.1", + "@scure/starknet": "1.1.0", + "@starknet-io/get-starknet-wallet-standard": "^5.0.0", + "@starknet-io/get-starknet-wallet-standard-v6": "npm:@starknet-io/get-starknet-wallet-standard@6.0.2", + "@starknet-io/starknet-types-0101": "npm:@starknet-io/types-js@0.10.2", + "@starknet-io/starknet-types-0103": "npm:@starknet-io/types-js@0.10.3", + "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@~0.9.2", + "abi-wan-kanabi": "2.2.4", + "lossless-json": "^4.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@starkware-libs/starknet-privacy-sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@stellar/js-xdr": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", @@ -3554,9 +3861,9 @@ } }, "node_modules/abitype": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.4.tgz", - "integrity": "sha512-dpKH+N27vRjarMVTFFkeY445VTKftzGWpL0FiT7xmVmzQRKazZexzC5uHG0f6XKsVLAuUlndnbGau6lRejClxg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/wevm" @@ -3829,6 +4136,22 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", @@ -4035,6 +4358,19 @@ "node": ">=6.0.0" } }, + "node_modules/bhttp-ts": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/bhttp-ts/-/bhttp-ts-0.4.5.tgz", + "integrity": "sha512-CDDrORHWA1OeLKgmjBM5aOW4/RS2bHoLEAIYcFq86sXdWJnJE/ZDOHBdx5lxP9QrJPP2TfBNpdlaP78D4zQ17g==", + "dev": true, + "license": "MIT", + "dependencies": { + "quicvarint": "^0.1.7" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -4050,6 +4386,17 @@ "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", "license": "MIT" }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/bn.js": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", @@ -4225,6 +4572,41 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4272,6 +4654,25 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4285,6 +4686,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4582,6 +5000,13 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4652,6 +5077,176 @@ } } }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress/node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4684,6 +5279,24 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delay": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", @@ -4832,6 +5445,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5397,6 +6020,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/feaxios": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", @@ -5419,6 +6052,16 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5542,12 +6185,28 @@ ], "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/form-data": { @@ -5577,6 +6236,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -5861,6 +6527,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5927,6 +6606,16 @@ "hermes-estree": "0.36.0" } }, + "node_modules/hpke": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/hpke/-/hpke-1.1.2.tgz", + "integrity": "sha512-6Z8JoG/zYXly+MfsgQ5aNibBx/HDq2F74254Tv8VK4DR3/Mmf6zVrGakqsodfy9pF0XGKSYh5MA9x6D30gbhbg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/hpke-js": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/hpke-js/-/hpke-js-1.8.0.tgz", @@ -6146,6 +6835,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -6221,6 +6923,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6266,6 +6975,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -6280,6 +7005,13 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8075,6 +8807,21 @@ "node": ">=0.10.0" } }, + "node_modules/ohttp-ts": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/ohttp-ts/-/ohttp-ts-0.3.5.tgz", + "integrity": "sha512-SHgU/QR71/0e9D06T5CFVJ/qgv4Siljk17B2avVBSORBp+wC/S0xo6elg0NVk73WqZKNj6ixeJIqoPQs329cJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bhttp-ts": "0.4.5", + "hpke": "1.1.2", + "quicvarint": "0.1.7" + }, + "engines": { + "node": ">=24" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -8222,12 +8969,6 @@ "node": ">=6" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8350,6 +9091,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8369,6 +9117,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -8483,6 +9264,16 @@ "node": ">=14.6" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -8564,6 +9355,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/promise": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", @@ -8675,6 +9473,13 @@ ], "license": "MIT" }, + "node_modules/quicvarint": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/quicvarint/-/quicvarint-0.1.7.tgz", + "integrity": "sha512-CW401DjRCKUS5fIq/s/qHhcm+kEwc097R8hN+ORngT2biSRsz5JIoAF7hMbdG/XRvEBVpuySmhmSJeJMxdoG8g==", + "dev": true, + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8823,6 +9628,29 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -9146,6 +9974,27 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -9278,6 +10127,24 @@ "node": ">= 0.8" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9470,9 +10337,9 @@ } }, "node_modules/starknet": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/starknet/-/starknet-9.4.2.tgz", - "integrity": "sha512-NFtg077DjddHUSh8sLZ5uB149LEF4NR90FuJ0oF7+zhce7l0oG9Ubqr3H4+jHm/ghHtV+yjlv1UhEoo4SBfILA==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/starknet/-/starknet-10.8.0.tgz", + "integrity": "sha512-skJ2tTmj/Qz/iXRRwDBmZQtymR020xZt4+4k3SBbPlHgELyiC3HnJjEO0hbUQsEsQVjjVbpWVQc9B+dLHvZvzQ==", "license": "MIT", "dependencies": { "@noble/curves": "~1.7.0", @@ -9480,17 +10347,30 @@ "@scure/base": "~1.2.1", "@scure/starknet": "1.1.0", "@starknet-io/get-starknet-wallet-standard": "^5.0.0", - "@starknet-io/starknet-types-010": "npm:@starknet-io/types-js@0.10.0", - "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@~0.9.1", + "@starknet-io/get-starknet-wallet-standard-v6": "npm:@starknet-io/get-starknet-wallet-standard@6.0.6", + "@starknet-io/starknet-types-0101": "npm:@starknet-io/types-js@0.10.2", + "@starknet-io/starknet-types-0103": "npm:@starknet-io/types-js@0.10.3", + "@starknet-io/starknet-types-0104": "npm:@starknet-io/types-js@0.10.4", + "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@0.9.2", "abi-wan-kanabi": "2.2.4", - "lossless-json": "^4.2.0", - "pako": "^2.0.4", - "ts-mixer": "^6.0.3" + "lossless-json": "^4.2.0" }, "engines": { "node": ">=22" } }, + "node_modules/starknet-devnet": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/starknet-devnet/-/starknet-devnet-0.7.2.tgz", + "integrity": "sha512-g4ayDPXZCe3dKNwIaaiiNkHmH1ZbqRnGbOLWYd2fGTodK1rR5+22Q27mHEbWrDkLdi4KMHBIxSBIaAFxvUMlNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.7.4", + "decompress": "^4.2.1", + "decompress-targz": "^4.1.1" + } + }, "node_modules/starknet/node_modules/@noble/curves": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", @@ -9553,6 +10433,23 @@ "stream-chain": "^2.2.5" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9603,6 +10500,16 @@ "node": ">=8" } }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -9683,6 +10590,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/terser": { "version": "5.49.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", @@ -9805,6 +10731,13 @@ "optional": true, "peer": true }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -9867,6 +10800,28 @@ "devOptional": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9993,12 +10948,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -10132,6 +11081,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -10178,6 +11142,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/unbzip2-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -10244,6 +11244,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -10460,6 +11467,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10590,6 +11619,16 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -10649,6 +11688,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10661,6 +11711,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 5c12939..9753450 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@stellar/stellar-sdk": "^16.0.1", "@turnkey/sdk-browser": "^6.1.1", "buffer": "^6.0.3", - "starknet": "^9.4.2" + "starknet": "^10.5.0" }, "peerDependencies": { "expo-linking": ">=7", @@ -102,7 +102,8 @@ "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "react-native": ">=0.76", - "react-native-get-random-values": ">=1.11" + "react-native-get-random-values": ">=1.11", + "@starkware-libs/starknet-privacy-sdk": "^0.14.3-rc.5" }, "peerDependenciesMeta": { "react": { @@ -128,9 +129,13 @@ }, "react-native-get-random-values": { "optional": true + }, + "@starkware-libs/starknet-privacy-sdk": { + "optional": true } }, "devDependencies": { + "@starkware-libs/starknet-privacy-sdk": "^0.14.3-rc.5", "@types/jest": "^29.5.14", "@types/node": "^22.9.0", "@types/react": "^19.0.0", diff --git a/src/chains/starknet/CavosStarknet.ts b/src/chains/starknet/CavosStarknet.ts index cc14375..1e5726a 100644 --- a/src/chains/starknet/CavosStarknet.ts +++ b/src/chains/starknet/CavosStarknet.ts @@ -31,6 +31,8 @@ import { type StarknetNetwork, } from "./constants"; import { resolveNativeStarknet, type StarknetSpend } from "./nativeConnect"; +import { parseStarknetPrivacyConfig, type StarknetPrivacyConfig } from "./privacy/config"; +import { createPrivacySession, unavailablePrivacy, type PrivacyOperations } from "./privacy/session"; export interface ConnectStarknetOptions { network: StarknetNetwork; @@ -54,6 +56,12 @@ export interface ConnectStarknetOptions { passkey?: PasskeyPrfProvider; /** Keep the key in the Cavos vault instead of this page's storage. */ vault?: VaultClient; + /** + * Private transfers (STRK20) for a key held in this page. With a vault the + * pool, prover, discovery and relay come from the app's Cavos settings, so + * passing this together with `vault` is refused. + */ + privacy?: StarknetPrivacyConfig; } const MESSAGE_TYPES = { @@ -66,6 +74,8 @@ const MESSAGE_TYPES = { Message: [{ name: "content", type: "string" }], }; +const NO_KEY = "kit/starknet: this device holds no key for the wallet"; + const KEY_ROTATIONS = new Set(["set_public_key", "setPublicKey"].map((name) => BigInt(hash.getSelectorFromName(name)))); /** @@ -81,6 +91,8 @@ export class CavosStarknet { readonly identity: Identity; readonly address: string; readonly network: StarknetNetwork; + /** Private balance, deposits, transfers and withdrawals in a STRK20 pool. Nothing here returns a key or a note. */ + readonly privacy: PrivacyOperations; isNewAccount = false; onAuthorizationNeeded?: () => Promise; private statusValue: ConnectStatus; @@ -106,6 +118,7 @@ export class CavosStarknet { account?: Account; publicKey?: string; restoredWithPasskey?: boolean; + privacy: PrivacyOperations; }) { this.identity = init.identity; this.address = init.address; @@ -116,6 +129,7 @@ export class CavosStarknet { this.accountValue = init.account; this.starkPublicKey = init.publicKey; this.passkeyRestore = init.restoredWithPasskey ?? false; + this.privacy = init.privacy; } get status(): ConnectStatus { @@ -180,16 +194,23 @@ export class CavosStarknet { }) : defaultRegistry); + if (opts.vault && opts.privacy) { + throw new Error("kit/starknet: with a vault, private transfer settings come from the app in the Cavos dashboard, not from `privacy`"); + } + const privacyConfig = opts.privacy ? parseStarknetPrivacyConfig(opts.privacy) : undefined; + let signer: SignerInterface | null; let address: string; let isNewAccount: boolean; let passkey: boolean; + let privacy: PrivacyOperations; if (opts.vault) { const native = await connectThroughVault("starknet", identity, opts.appSalt, () => - opts.vault!.connectStarknet(vaultConnectParams({ ...opts, identity })), + opts.vault!.connectStarknet(vaultConnectParams({ ...opts, identity }), opts.paymasterApiKey), ); ({ signer, address, isNewAccount } = native); passkey = native.passkey; + privacy = native.privacy ?? unavailablePrivacy(NO_KEY); } else { const native = await resolveNativeStarknet({ identity, @@ -212,6 +233,17 @@ export class CavosStarknet { signer = native.spend ? new Signer(native.spend.privateKey()) : null; ({ address, isNewAccount } = native); passkey = Boolean(opts.passkey); + privacy = !native.spend + ? unavailablePrivacy(NO_KEY) + : privacyConfig + ? createPrivacySession({ + config: privacyConfig, + chainId: STARKNET_NETWORKS[opts.network].chainId, + rpcUrl: opts.rpcUrl ?? STARKNET_NETWORKS[opts.network].rpcUrl, + account: { address, spend: native.spend }, + paymasterApiKey: () => opts.paymasterApiKey, + }) + : unavailablePrivacy("kit/starknet: private transfers need `privacy` in the connect options when the key is held by this page"); } const deployed = await isDeployed(provider, address); @@ -229,6 +261,7 @@ export class CavosStarknet { } : {}), restoredWithPasskey: passkey, + privacy, }); wallet.isNewAccount = isNewAccount; return wallet; diff --git a/src/chains/starknet/privacy/chain.ts b/src/chains/starknet/privacy/chain.ts new file mode 100644 index 0000000..6834537 --- /dev/null +++ b/src/chains/starknet/privacy/chain.ts @@ -0,0 +1,97 @@ +import { RpcProvider, num, shortString } from "starknet"; + +/** How many blocks a proof's base block must trail the chain head. Matches the pool's acceptance window. */ +export const PROOF_BASE_BLOCK_DEPTH = 10; + +/** The chain reads a private operation needs. */ +export interface PrivacyChain { + blockNumber(): Promise; + call(contract: string, entrypoint: string, calldata?: string[]): Promise; + /** Block the transaction landed in, or -1 when it reverted. */ + confirm(transactionHash: string, options: PollOptions): Promise; +} + +export interface PollOptions { + pollIntervalMs: number; + timeoutMs: number; +} + +export const DEFAULT_POLL: PollOptions = { pollIntervalMs: 2_000, timeoutMs: 300_000 }; + +export function rpcChain(rpcUrl: string): PrivacyChain { + const provider = new RpcProvider({ nodeUrl: rpcUrl }); + return { + blockNumber: () => provider.getBlockNumber(), + call: (contractAddress, entrypoint, calldata = []) => provider.callContract({ contractAddress, entrypoint, calldata }), + async confirm(transactionHash, { pollIntervalMs, timeoutMs }) { + const receipt = await provider.waitForTransaction(transactionHash, { + errorStates: [], + retryInterval: pollIntervalMs, + retries: Math.max(1, Math.ceil(timeoutMs / pollIntervalMs)), + }); + const block = (receipt as unknown as { block_number?: number }).block_number; + const failed = (receipt as unknown as { isError(): boolean }).isError(); + return failed || block === undefined ? -1 : block; + }, + }; +} + +/** + * Block to prove against: `depth` behind the head, and strictly past + * `sinceBlock`, the block holding the state the proof must see. + */ +export async function provableBlock( + chain: PrivacyChain, + sinceBlock: number, + poll: PollOptions, + depth = PROOF_BASE_BLOCK_DEPTH, +): Promise { + const deadline = Date.now() + poll.timeoutMs; + for (;;) { + const head = await chain.blockNumber(); + if (sinceBlock < head - depth) return head - depth; + if (Date.now() >= deadline) { + throw new Error(`kit/starknet: block ${sinceBlock} did not become ${depth} blocks old within ${poll.timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, poll.pollIntervalMs)); + } +} + +const SNOS_TAG = BigInt(shortString.encodeShortString("VIRTUAL_SNOS0")); + +/** The block a proof was made against, read from its proof facts. Undefined for a layout this does not know. */ +export function proofBaseBlock(proofFacts: string[]): number | undefined { + const facts = proofFacts.map((fact) => num.toBigInt(fact)); + const at = facts.indexOf(SNOS_TAG) + 1; + if (at === 0 || at >= facts.length) return undefined; + const block = Number(facts[at]); + return Number.isSafeInteger(block) && block > 0 ? block : undefined; +} + +/** Refuses a proof the sequencer or the pool would reject, before the relay pays to submit it. */ +export async function assertProofFresh( + chain: PrivacyChain, + pool: string, + proofFacts: string[], + depth = PROOF_BASE_BLOCK_DEPTH, +): Promise { + const base = proofBaseBlock(proofFacts); + if (base === undefined) return; + const [head, validity] = await Promise.all([chain.blockNumber(), proofValidityBlocks(chain, pool)]); + const age = head - base; + if (age < 0) throw new Error(`kit/starknet: the proof is for block ${base}, ahead of the head ${head}; the RPC node is behind the prover`); + if (age < depth) throw new Error(`kit/starknet: the proof is only ${age} blocks old and the pool needs ${depth}; prove again`); + if (validity !== undefined && age > validity) { + throw new Error(`kit/starknet: the proof is ${age} blocks old and the pool accepts ${validity}; prove again`); + } +} + +async function proofValidityBlocks(chain: PrivacyChain, pool: string): Promise { + try { + const [value] = await chain.call(pool, "get_proof_validity_blocks"); + const blocks = Number(num.toBigInt(value ?? "")); + return Number.isSafeInteger(blocks) && blocks > 0 ? blocks : undefined; + } catch { + return undefined; + } +} diff --git a/src/chains/starknet/privacy/config.test.ts b/src/chains/starknet/privacy/config.test.ts new file mode 100644 index 0000000..1248798 --- /dev/null +++ b/src/chains/starknet/privacy/config.test.ts @@ -0,0 +1,62 @@ +import { parseStarknetPrivacyConfig } from "./config"; + +const valid = { + pool: "0x0123", + prover: "https://prover.example.com/", + discovery: "https://discovery.example.com", + relay: { + url: "https://relay.example.com", + fee: { mode: "sponsored", pool_fee: { paid_by: "user", token: "0x04718f" } }, + maxFee: "1000000", + allowedFeeRecipients: ["0x75a1"], + }, +}; + +describe("privacy config", () => { + it("normalises a valid config", () => { + expect(parseStarknetPrivacyConfig(valid)).toEqual({ + pool: "0x123", + prover: "https://prover.example.com", + discovery: "https://discovery.example.com", + relay: { + url: "https://relay.example.com", + fee: { mode: "sponsored", pool_fee: { paid_by: "user", token: "0x4718f" } }, + maxFee: "1000000", + allowedFeeRecipients: ["0x75a1"], + }, + }); + }); + + it("accepts the other fee modes, ohttp and an rpc url", () => { + const parsed = parseStarknetPrivacyConfig({ + ...valid, + ohttp: false, + rpcUrl: "http://localhost:5050", + relay: { ...valid.relay, fee: { mode: "default", gas_token: "0x1" } }, + }); + expect(parsed.ohttp).toBe(false); + expect(parsed.rpcUrl).toBe("http://localhost:5050"); + expect(parsed.relay.fee).toEqual({ mode: "default", gas_token: "0x1" }); + expect( + parseStarknetPrivacyConfig({ ...valid, relay: { ...valid.relay, fee: { mode: "sponsored", pool_fee: { paid_by: "sponsor" } } } }).relay.fee, + ).toEqual({ mode: "sponsored", pool_fee: { paid_by: "sponsor" } }); + }); + + it.each([ + ["a missing pool", { ...valid, pool: undefined }, /privacy\.pool/], + ["a malformed pool", { ...valid, pool: "pool" }, /privacy\.pool/], + ["a zero pool", { ...valid, pool: "0x0" }, /privacy\.pool/], + ["an unknown field", { ...valid, extra: 1 }, /privacy\.extra/], + ["a plain http prover", { ...valid, prover: "http://prover.example.com" }, /privacy\.prover.*https/], + ["credentials in a url", { ...valid, discovery: "https://u:p@d.example.com" }, /credentials/], + ["a non-boolean ohttp", { ...valid, ohttp: "yes" }, /privacy\.ohttp/], + ["a numeric maxFee", { ...valid, relay: { ...valid.relay, maxFee: 5 } }, /maxFee/], + ["a negative maxFee", { ...valid, relay: { ...valid.relay, maxFee: "-5" } }, /maxFee/], + ["no fee recipients", { ...valid, relay: { ...valid.relay, allowedFeeRecipients: [] } }, /allowedFeeRecipients/], + ["a bad fee mode", { ...valid, relay: { ...valid.relay, fee: { mode: "free" } } }, /fee\.mode/], + ["a user fee without a token", { ...valid, relay: { ...valid.relay, fee: { mode: "sponsored", pool_fee: { paid_by: "user" } } } }, /token/], + ["a non-object", "nope", /privacy must be an object/], + ])("rejects %s", (_name, input, message) => { + expect(() => parseStarknetPrivacyConfig(input)).toThrow(message); + }); +}); diff --git a/src/chains/starknet/privacy/config.ts b/src/chains/starknet/privacy/config.ts new file mode 100644 index 0000000..1696cd7 --- /dev/null +++ b/src/chains/starknet/privacy/config.ts @@ -0,0 +1,138 @@ +/** Who pays the privacy pool fee, and in what, as the paymaster spells it. */ +export type PrivacyFeeMode = + | { mode: "sponsored"; pool_fee: { paid_by: "user"; token: string } } + | { mode: "sponsored"; pool_fee: { paid_by: "sponsor" } } + | { mode: "default"; gas_token: string }; + +export interface PrivacyRelayConfig { + /** Paymaster that submits private transactions, so the account never appears on-chain. */ + url: string; + fee: PrivacyFeeMode; + /** Ceiling for a fee the relay asks for, in base units of the fee token. */ + maxFee: string; + /** The only addresses a fee may be sent to. Also the only callers a signed relay call may name. */ + allowedFeeRecipients: string[]; +} + +/** + * Everything the page must not choose: the pool, the services that see the + * viewing key, and who is paid. In a vault it comes from the app's Cavos + * settings, keyed by network ("sepolia", "mainnet"). + */ +export interface StarknetPrivacyConfig { + pool: string; + prover: string; + discovery: string; + /** Encrypt requests to the prover and discovery service so they cannot read the viewing key. Defaults to true. */ + ohttp?: boolean; + /** Proofs need RPC spec 0.10.1 or newer. */ + rpcUrl?: string; + relay: PrivacyRelayConfig; +} + +const TOP_KEYS = ["pool", "prover", "discovery", "ohttp", "rpcUrl", "relay"]; +const RELAY_KEYS = ["url", "fee", "maxFee", "allowedFeeRecipients"]; +const FELT_LIMIT = 1n << 252n; + +/** Strict: unknown fields, bad URLs and malformed addresses throw an error that names the field. */ +export function parseStarknetPrivacyConfig(input: unknown): StarknetPrivacyConfig { + const config = record(input, "privacy"); + onlyKeys(config, TOP_KEYS, "privacy"); + const relay = record(config.relay, "privacy.relay"); + onlyKeys(relay, RELAY_KEYS, "privacy.relay"); + if (config.ohttp !== undefined && typeof config.ohttp !== "boolean") { + throw invalid("privacy.ohttp", "must be true or false"); + } + return { + pool: address(config.pool, "privacy.pool"), + prover: serviceUrl(config.prover, "privacy.prover"), + discovery: serviceUrl(config.discovery, "privacy.discovery"), + ...(config.ohttp !== undefined ? { ohttp: config.ohttp } : {}), + ...(config.rpcUrl !== undefined ? { rpcUrl: serviceUrl(config.rpcUrl, "privacy.rpcUrl") } : {}), + relay: { + url: serviceUrl(relay.url, "privacy.relay.url"), + fee: feeMode(relay.fee), + maxFee: baseUnits(relay.maxFee, "privacy.relay.maxFee"), + allowedFeeRecipients: recipients(relay.allowedFeeRecipients), + }, + }; +} + +function feeMode(value: unknown): PrivacyFeeMode { + const fee = record(value, "privacy.relay.fee"); + if (fee.mode === "default") { + onlyKeys(fee, ["mode", "gas_token"], "privacy.relay.fee"); + return { mode: "default", gas_token: address(fee.gas_token, "privacy.relay.fee.gas_token") }; + } + if (fee.mode === "sponsored") { + onlyKeys(fee, ["mode", "pool_fee"], "privacy.relay.fee"); + const pool = record(fee.pool_fee, "privacy.relay.fee.pool_fee"); + if (pool.paid_by === "sponsor") { + onlyKeys(pool, ["paid_by"], "privacy.relay.fee.pool_fee"); + return { mode: "sponsored", pool_fee: { paid_by: "sponsor" } }; + } + if (pool.paid_by === "user") { + onlyKeys(pool, ["paid_by", "token"], "privacy.relay.fee.pool_fee"); + return { + mode: "sponsored", + pool_fee: { paid_by: "user", token: address(pool.token, "privacy.relay.fee.pool_fee.token") }, + }; + } + throw invalid("privacy.relay.fee.pool_fee.paid_by", 'must be "user" or "sponsor"'); + } + throw invalid("privacy.relay.fee.mode", 'must be "sponsored" or "default"'); +} + +function recipients(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw invalid("privacy.relay.allowedFeeRecipients", "must list at least one address, or no fee can ever be accepted"); + } + return value.map((entry, index) => address(entry, `privacy.relay.allowedFeeRecipients[${index}]`)); +} + +function baseUnits(value: unknown, path: string): string { + if (typeof value !== "string" || !/^\d+$/.test(value)) { + throw invalid(path, "must be a decimal string of base units, for example \"1000000\""); + } + return BigInt(value).toString(); +} + +function address(value: unknown, path: string): string { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{1,64}$/.test(value)) { + throw invalid(path, "must be a 0x-prefixed hex address"); + } + const felt = BigInt(value); + if (felt === 0n || felt >= FELT_LIMIT) throw invalid(path, "is not a valid Starknet address"); + return `0x${felt.toString(16)}`; +} + +function serviceUrl(value: unknown, path: string): string { + if (typeof value !== "string") throw invalid(path, "must be a URL"); + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw invalid(path, "is not a valid URL"); + } + const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw invalid(path, "must use https (http is only accepted for localhost)"); + } + if (url.username || url.password) throw invalid(path, "must not contain credentials"); + return url.toString().replace(/\/+$/, ""); +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid(path, "must be an object"); + return value as Record; +} + +function onlyKeys(value: Record, allowed: string[], path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw invalid(`${path}.${key}`, "is not a known setting"); + } +} + +function invalid(path: string, problem: string): Error { + return new Error(`kit/starknet: ${path} ${problem}`); +} diff --git a/src/chains/starknet/privacy/fixtures.ts b/src/chains/starknet/privacy/fixtures.ts new file mode 100644 index 0000000..dee0673 --- /dev/null +++ b/src/chains/starknet/privacy/fixtures.ts @@ -0,0 +1,168 @@ +import { ec, num, shortString, type TypedData } from "starknet"; +import { Mocknet, MockProofInvocationFactory, MockProofProvider, ContractDiscoveryProvider } from "@starkware-libs/starknet-privacy-sdk/testing"; +import * as sdk from "@starkware-libs/starknet-privacy-sdk"; +import type { PrivacyChain } from "./chain"; +import type { PrivacyFeeMode, StarknetPrivacyConfig } from "./config"; +import type { PrivacySessionInit } from "./session"; + +export const STRK = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +export const FORWARDER = "0x75a1"; +export const PRIVATE_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef"; +export const CHAIN_ID = "0x534e5f5345504f4c4941"; + +export interface MockPrivacyOptions { + fee?: PrivacyFeeMode; + feeAmount?: bigint; + maxFee?: string; + poolFee?: bigint; + quotedRecipient?: string; +} + +/** The SDK's in-memory pool behind a fake paymaster, so a whole private flow runs without a chain. */ +export function mockPrivacy(options: MockPrivacyOptions = {}) { + const mocknet = new Mocknet({ poolAddress: 0x123n }); + const env = mocknet.initialize(); + const pool = hex(mocknet.poolAddress); + const ace = hex(BigInt(env.ace)); + const bee = hex(BigInt(env.bee)); + const strk = hex(BigInt(STRK)); + for (const account of [env.alice, env.bob]) mocknet.fundUser(account.address, strk, 10n ** 21n); + const alice = hex(env.alice.address); + const bob = hex(env.bob.address); + const fee: PrivacyFeeMode = options.fee ?? { mode: "sponsored", pool_fee: { paid_by: "sponsor" } }; + const feeAmount = options.feeAmount ?? 0n; + const built: { method: string; params: any; headers: Record }[] = []; + const executed: { method: string; params: any }[] = []; + let head = 1000; + let submissions = 0; + + const config: StarknetPrivacyConfig = { + pool, + prover: "https://prover.test", + discovery: "https://discovery.test", + relay: { + url: "https://relay.test", + fee, + maxFee: options.maxFee ?? "1000", + allowedFeeRecipients: [FORWARDER], + }, + }; + + const chain: PrivacyChain = { + blockNumber: async () => head, + call: async (_contract, entrypoint, calldata = []) => { + if (entrypoint === "get_public_key") return [num.toHex(mocknet.pool.get_public_key(BigInt(calldata[0]!)))]; + if (entrypoint === "get_fee_amount") return [num.toHex(options.poolFee ?? 10n ** 18n)]; + throw new Error(`unexpected call ${entrypoint}`); + }, + confirm: async () => head - 20, + }; + + const relayFetch = (async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { method: string; params: any }; + const headers = (init?.headers ?? {}) as Record; + const reply = (result: unknown) => ({ ok: true, status: 200, json: async () => ({ result }) }) as Response; + if (body.method === "paymaster_buildTransaction") { + built.push({ ...body, headers }); + const requested = body.params.parameters.fee_mode as PrivacyFeeMode; + const sponsor = requested.mode === "sponsored" && requested.pool_fee.paid_by === "sponsor"; + const token = requested.mode === "default" ? requested.gas_token : requested.pool_fee.paid_by === "user" ? requested.pool_fee.token : ace; + const calls = body.params.transaction.invoke?.calls as { to: string; selector: string; calldata: string[] }[] | undefined; + const typed_data: TypedData | undefined = calls && { + domain: { name: "Account.execute_from_outside", version: "2", chainId: "SN_SEPOLIA", revision: "1" }, + primaryType: "OutsideExecution", + types: { + StarknetDomain: [ + { name: "name", type: "shortstring" }, + { name: "version", type: "shortstring" }, + { name: "chainId", type: "shortstring" }, + { name: "revision", type: "shortstring" }, + ], + OutsideExecution: [ + { name: "Caller", type: "ContractAddress" }, + { name: "Nonce", type: "felt" }, + { name: "Execute After", type: "u128" }, + { name: "Execute Before", type: "u128" }, + { name: "Calls", type: "Call*" }, + ], + Call: [ + { name: "To", type: "ContractAddress" }, + { name: "Selector", type: "selector" }, + { name: "Calldata", type: "felt*" }, + ], + }, + message: { + Caller: FORWARDER, + Nonce: "0x1", + "Execute After": "0x1", + "Execute Before": `0x${(Math.floor(Date.now() / 1000) + 3600).toString(16)}`, + Calls: calls.map((call) => ({ To: call.to, Selector: call.selector, Calldata: call.calldata })), + }, + }; + return reply({ + parameters: body.params.parameters, + fee_action: sponsor + ? { type: "none" } + : { type: "withdraw", recipient: options.quotedRecipient ?? FORWARDER, token, amount: num.toHex(feeAmount) }, + ...(typed_data ? { typed_data } : {}), + }); + } + executed.push(body); + mocknet.pool.apply_actions(body.params.transaction.apply_action.apply_actions_call.calldata.map(symbolic)); + submissions += 1; + return reply({ transaction_hash: `0x${submissions.toString(16)}aa`, tracking_id: `0x${submissions}` }); + }) as unknown as typeof fetch; + + const init: PrivacySessionInit = { + config, + chainId: CHAIN_ID, + rpcUrl: "https://rpc.test", + account: { address: alice, spend: { privateKey: () => PRIVATE_KEY } }, + paymasterApiKey: () => "key-1", + deps: { + chain, + sdk, + provingProvider: new MockProofProvider(mocknet.pool), + discoveryProvider: new ContractDiscoveryProvider(mocknet.pool), + proofInvocationFactory: new MockProofInvocationFactory(), + fetch: relayFetch, + poll: { pollIntervalMs: 1, timeoutMs: 1000 }, + }, + }; + + return { + mocknet, + env, + config, + init, + pool, + ace, + bee, + strk, + alice, + bob, + built, + executed, + setHead: (value: number) => { + head = value; + }, + bobInit: { + ...init, + account: { address: bob, spend: { privateKey: () => "0x7777777777777777" } }, + } as PrivacySessionInit, + publicKeyOf: (privateKey: string) => ec.starkCurve.getStarkKey(privateKey), + }; +} + +function hex(value: bigint): string { + return `0x${value.toString(16)}`; +} + +function symbolic(felt: string): string { + try { + const text = shortString.decodeShortString(felt); + return /^[A-Z][A-Za-z]{2,}$/.test(text) ? text : felt; + } catch { + return felt; + } +} diff --git a/src/chains/starknet/privacy/index.ts b/src/chains/starknet/privacy/index.ts new file mode 100644 index 0000000..8e3de4c --- /dev/null +++ b/src/chains/starknet/privacy/index.ts @@ -0,0 +1,4 @@ +export { parseStarknetPrivacyConfig } from "./config"; +export type { PrivacyFeeMode, PrivacyRelayConfig, StarknetPrivacyConfig } from "./config"; +export { PrivacySession, createPrivacySession } from "./session"; +export type { PrivacyOperations, PrivacyRequirement, PrivacyStatus, PrivacySubmission } from "./session"; diff --git a/src/chains/starknet/privacy/relay.test.ts b/src/chains/starknet/privacy/relay.test.ts new file mode 100644 index 0000000..f858c05 --- /dev/null +++ b/src/chains/starknet/privacy/relay.test.ts @@ -0,0 +1,118 @@ +import { PrivacyRelay, type RelayInvoke } from "./relay"; +import type { PrivacyFeeMode } from "./config"; + +const FORWARDER = "0x75a1"; +const STRK = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +const POOL = "0x123"; +const USER_FEE: PrivacyFeeMode = { mode: "sponsored", pool_fee: { paid_by: "user", token: STRK } }; +const SPONSOR_FEE: PrivacyFeeMode = { mode: "sponsored", pool_fee: { paid_by: "sponsor" } }; + +function relay(result: unknown, options: { maxFee?: bigint; published?: bigint } = {}) { + const requests: any[] = []; + const instance = new PrivacyRelay({ + url: "https://relay.test", + apiKey: () => "key", + maxFee: options.maxFee ?? 100n, + allowedFeeRecipients: [FORWARDER], + publishedFee: async () => options.published ?? 50n, + publishedFeeToken: STRK, + fetch: (async (_url: string, init?: RequestInit) => { + requests.push(JSON.parse(String(init?.body))); + return { ok: true, status: 200, json: async () => ({ result }) } as Response; + }) as unknown as typeof fetch, + }); + return { instance, requests }; +} + +const withdraw = (overrides: Record = {}) => ({ + fee_action: { type: "withdraw", recipient: FORWARDER, token: STRK, amount: "0x10", ...overrides }, + parameters: { version: "0x1" }, +}); + +describe("relay quotes", () => { + it("accepts a fee within every bound", async () => { + const { instance } = relay(withdraw()); + const quote = await instance.quote(POOL, USER_FEE); + expect(quote.feeAction).toEqual({ recipient: FORWARDER, token: `0x${BigInt(STRK).toString(16)}`, amount: 16n }); + }); + + it("refuses a fee sent to a recipient the app did not list", async () => { + const { instance } = relay(withdraw({ recipient: "0xbad" })); + await expect(instance.quote(POOL, USER_FEE)).rejects.toThrow(/does not allow/); + }); + + it("refuses a fee above maxFee", async () => { + const { instance } = relay(withdraw({ amount: "0x65" })); + await expect(instance.quote(POOL, USER_FEE)).rejects.toThrow(/above the 100/); + }); + + it("refuses a pool fee above what the pool publishes", async () => { + const { instance } = relay(withdraw({ amount: "0x40" }), { maxFee: 1000n, published: 50n }); + await expect(instance.quote(POOL, USER_FEE)).rejects.toThrow(/pool publishes/); + }); + + it("refuses a fee in a token the app did not choose", async () => { + const { instance } = relay(withdraw({ token: "0x99" })); + await expect(instance.quote(POOL, USER_FEE)).rejects.toThrow(/did not choose/); + }); + + it("refuses to withdraw a fee the sponsor was meant to pay", async () => { + const { instance } = relay(withdraw()); + await expect(instance.quote(POOL, SPONSOR_FEE)).rejects.toThrow(/sponsor pays/); + }); + + it("returns no fee action when the sponsor pays", async () => { + const { instance, requests } = relay({ fee_action: { type: "none" }, parameters: {} }); + expect((await instance.quote(POOL, SPONSOR_FEE)).feeAction).toBeNull(); + expect(requests[0].method).toBe("paymaster_buildTransaction"); + expect(requests[0].params.parameters).toEqual({ version: "0x1", fee_mode: SPONSOR_FEE }); + }); +}); + +describe("relay approval typed data", () => { + const invoke: RelayInvoke = { + userAddress: "0xacc", + chainId: "SN_SEPOLIA", + calls: [{ to: "0x1", selector: "0x2", calldata: ["0x3", "0x4"] }], + }; + const typed = (overrides: Record = {}, domain: Record = {}) => ({ + ...withdraw({ amount: "0x1" }), + typed_data: { + primaryType: "OutsideExecution", + types: {}, + domain: { name: "Account.execute_from_outside", version: "2", chainId: "SN_SEPOLIA", revision: "1", ...domain }, + message: { + Caller: FORWARDER, + Nonce: "0x1", + "Execute After": "0x1", + "Execute Before": `0x${(Math.floor(Date.now() / 1000) + 600).toString(16)}`, + Calls: [{ To: "0x1", Selector: "0x2", Calldata: ["0x3", "0x4"] }], + ...overrides, + }, + }, + }); + + it("accepts the calls that were requested", async () => { + const { instance } = relay(typed()); + expect((await instance.quote(POOL, USER_FEE, invoke)).typedData).toBeDefined(); + }); + + it.each([ + ["another contract", { Calls: [{ To: "0x9", Selector: "0x2", Calldata: ["0x3", "0x4"] }] }, {}], + ["another entrypoint", { Calls: [{ To: "0x1", Selector: "0x9", Calldata: ["0x3", "0x4"] }] }, {}], + ["other arguments", { Calls: [{ To: "0x1", Selector: "0x2", Calldata: ["0x3", "0x5"] }] }, {}], + ["an extra call", { Calls: [{ To: "0x1", Selector: "0x2", Calldata: ["0x3", "0x4"] }, { To: "0x1", Selector: "0x2", Calldata: [] }] }, {}], + ["a caller that is not allowed", { Caller: "0xbad" }, {}], + ["an expired signature", { "Execute Before": "0x1" }, {}], + ["another chain", {}, { chainId: "SN_MAIN" }], + ["another domain", {}, { name: "Other" }], + ])("refuses %s", async (_name, message, domain) => { + const { instance } = relay(typed(message, domain)); + await expect(instance.quote(POOL, USER_FEE, invoke)).rejects.toThrow(/nothing was signed/); + }); + + it("refuses a relay that returns nothing to sign", async () => { + const { instance } = relay(withdraw()); + await expect(instance.quote(POOL, USER_FEE, invoke)).rejects.toThrow(/nothing to sign/); + }); +}); diff --git a/src/chains/starknet/privacy/relay.ts b/src/chains/starknet/privacy/relay.ts new file mode 100644 index 0000000..79981ef --- /dev/null +++ b/src/chains/starknet/privacy/relay.ts @@ -0,0 +1,235 @@ +import { num, shortString, stark, type Signature, type TypedData } from "starknet"; +import type { PrivacyFeeMode } from "./config"; + +export interface RelayCall { + to: string; + selector: string; + calldata: string[]; +} + +export interface RelayInvoke { + userAddress: string; + calls: RelayCall[]; + chainId: string; +} + +export interface FeeAction { + recipient: string; + token: string; + amount: bigint; +} + +export interface RelayQuote { + /** Null when the sponsor pays the pool fee: the proof must carry no fee withdrawal. */ + feeAction: FeeAction | null; + typedData?: TypedData; + parameters: unknown; +} + +export interface RelayProof { + data: string; + proofFacts: string[]; +} + +export interface RelayOptions { + url: string; + apiKey: () => string; + maxFee: bigint; + allowedFeeRecipients: string[]; + /** The fee the pool itself publishes. A user-paid quote above it is refused. */ + publishedFee: () => Promise; + /** Token the published fee is denominated in; quotes in other tokens are bounded by `maxFee` alone. */ + publishedFeeToken: string; + fetch?: typeof fetch; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 120_000; +const SNIP9_DOMAIN = "Account.execute_from_outside"; +const SNIP9_REVISIONS = [ + { version: 1n, revision: 0n }, + { version: 2n, revision: 1n }, +]; + +/** Talks to the paymaster's private transaction endpoints and refuses anything that costs the user more than the app allowed. */ +export class PrivacyRelay { + private readonly allowed: bigint[]; + + constructor(private readonly opts: RelayOptions) { + this.allowed = opts.allowedFeeRecipients.map((entry) => BigInt(entry)); + } + + async quote(pool: string, fee: PrivacyFeeMode, invoke?: RelayInvoke): Promise { + const apply_action = { pool_address: pool }; + const result = await this.send<{ + fee_action?: { type?: string; recipient?: string; token?: string; amount?: string }; + typed_data?: TypedData; + parameters?: unknown; + }>("paymaster_buildTransaction", { + transaction: invoke + ? { type: "invoke_and_apply_action", apply_action, invoke: { user_address: invoke.userAddress, calls: invoke.calls } } + : { type: "apply_action", apply_action }, + parameters: { version: "0x1", fee_mode: fee }, + }); + + const feeAction = await this.checkFee(result.fee_action, fee); + if (invoke) { + if (!result.typed_data) throw new Error("kit/starknet: the relay returned nothing to sign for the approval"); + this.checkTypedData(result.typed_data, invoke); + } + return { + feeAction, + ...(result.typed_data ? { typedData: result.typed_data } : {}), + parameters: result.parameters ?? { version: "0x1", fee_mode: fee }, + }; + } + + async execute( + call: RelayCall, + proof: RelayProof, + parameters: unknown, + invoke?: { userAddress: string; typedData: TypedData; signature: Signature }, + ): Promise<{ transactionHash: string }> { + const apply_action = { apply_actions_call: call, proof: proof.data, proof_facts: proof.proofFacts }; + const result = await this.send<{ transaction_hash?: unknown }>("paymaster_executeTransaction", { + transaction: invoke + ? { + type: "invoke_and_apply_action", + apply_action, + invoke: { + user_address: invoke.userAddress, + typed_data: invoke.typedData, + signature: stark.signatureToHexArray(invoke.signature), + }, + } + : { type: "apply_action", apply_action }, + parameters, + }); + if (typeof result.transaction_hash !== "string" || !result.transaction_hash) { + throw new Error("kit/starknet: the relay returned no transaction hash; the transaction may still have been submitted"); + } + return { transactionHash: result.transaction_hash }; + } + + private async checkFee( + action: { type?: string; recipient?: string; token?: string; amount?: string } | undefined, + fee: PrivacyFeeMode, + ): Promise { + if (!action) throw new Error("kit/starknet: the relay returned no fee action"); + const sponsored = fee.mode === "sponsored" && fee.pool_fee.paid_by === "sponsor"; + if (action.type === "none") return null; + if (sponsored) throw new Error("kit/starknet: the relay asked for a fee although the sponsor pays it; nothing was withdrawn"); + if (action.type !== "withdraw") throw new Error("kit/starknet: the relay returned a fee action Cavos does not know"); + + const recipient = felt(action.recipient); + const token = felt(action.token); + const amount = felt(action.amount); + if (recipient === undefined || token === undefined || amount === undefined) { + throw new Error("kit/starknet: the relay returned a malformed fee action"); + } + if (!this.allowed.includes(recipient)) { + throw new Error(`kit/starknet: the relay wants its fee sent to ${hex(recipient)}, which this app does not allow; nothing was withdrawn`); + } + const chosen = fee.mode === "default" ? fee.gas_token : fee.pool_fee.paid_by === "user" ? fee.pool_fee.token : undefined; + if (chosen !== undefined && BigInt(chosen) !== token) { + throw new Error("kit/starknet: the relay quoted its fee in a token the app did not choose; nothing was withdrawn"); + } + if (amount > this.opts.maxFee) { + throw new Error(`kit/starknet: the relay quoted a fee of ${amount}, above the ${this.opts.maxFee} this app allows; nothing was withdrawn`); + } + if (fee.mode === "sponsored" && token === BigInt(this.opts.publishedFeeToken)) { + const published = await this.opts.publishedFee(); + if (amount > published) { + throw new Error(`kit/starknet: the relay quoted a pool fee of ${amount}, above the ${published} the pool publishes; nothing was withdrawn`); + } + } + return { recipient: hex(recipient), token: hex(token), amount }; + } + + private checkTypedData(typedData: TypedData, invoke: RelayInvoke): void { + const refuse = (why: string): never => { + throw new Error(`kit/starknet: the relay asked for a signature over something other than the requested approval (${why}); nothing was signed`); + }; + if (typedData.primaryType !== "OutsideExecution") refuse("not an outside execution"); + + const domain = (typedData.domain ?? {}) as Record; + if (shortFelt(domain.chainId) !== shortFelt(invoke.chainId)) refuse("another chain"); + if (shortFelt(domain.name) !== shortFelt(SNIP9_DOMAIN)) refuse("another domain"); + const version = shortFelt(domain.version); + const revision = domain.revision === undefined ? 0n : shortFelt(domain.revision); + if (!SNIP9_REVISIONS.some((known) => known.version === version && known.revision === revision)) refuse("another domain version"); + + const message = (typedData.message ?? {}) as Record; + const caller = felt(message.Caller); + if (caller === undefined || !this.allowed.includes(caller)) refuse("the caller is not an address this app allows"); + + const now = BigInt(Math.floor(Date.now() / 1000)); + const before = felt(message["Execute Before"]); + if (before === undefined || before <= now) refuse("it has expired or never expires"); + const after = felt(message["Execute After"]); + if (after !== undefined && after > now) refuse("it is not valid yet"); + + const calls = message.Calls; + if (!Array.isArray(calls) || calls.length !== invoke.calls.length) refuse("a different number of calls"); + invoke.calls.forEach((expected, index) => { + const actual = ((calls as unknown[])[index] ?? {}) as Record; + if (felt(actual.To) !== felt(expected.to)) refuse(`call ${index} targets another contract`); + if (felt(actual.Selector) !== felt(expected.selector)) refuse(`call ${index} runs another entrypoint`); + const calldata = actual.Calldata; + if (!Array.isArray(calldata) || calldata.length !== expected.calldata.length) refuse(`call ${index} has other arguments`); + expected.calldata.forEach((value, position) => { + if (felt((calldata as unknown[])[position]) !== felt(value)) refuse(`call ${index} has other arguments`); + }); + }); + } + + private async send(method: string, params: unknown): Promise { + const send = this.opts.fetch ?? globalThis.fetch; + const response = await send(this.opts.url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", "x-paymaster-api-key": this.opts.apiKey() }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: AbortSignal.timeout(this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), + }).catch((error: unknown) => { + if ((error as { name?: string } | null)?.name === "TimeoutError") { + throw new Error(`kit/starknet: the relay did not answer ${method} in time`); + } + throw error; + }); + const body = (await response.json().catch(() => undefined)) as + | { result?: T; error?: { code?: number; message?: string; data?: unknown } } + | undefined; + if (body?.error && typeof body.error.code === "number") { + const detail = typeof body.error.data === "string" ? `: ${body.error.data}` : ""; + throw new Error(`kit/starknet: the relay rejected ${method} (code ${body.error.code}): ${body.error.message ?? "no message"}${detail}`); + } + if (!response.ok || body?.result === undefined) { + throw new Error(`kit/starknet: the relay returned no result for ${method} (HTTP ${response.status})`); + } + return body.result; + } +} + +function felt(value: unknown): bigint | undefined { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") return undefined; + try { + return num.toBigInt(value); + } catch { + return undefined; + } +} + +function shortFelt(value: unknown): bigint | undefined { + const parsed = felt(value); + if (parsed !== undefined) return parsed; + if (typeof value !== "string" || !value) return undefined; + try { + return BigInt(shortString.encodeShortString(value)); + } catch { + return undefined; + } +} + +function hex(value: bigint): string { + return `0x${value.toString(16)}`; +} diff --git a/src/chains/starknet/privacy/sdk.ts b/src/chains/starknet/privacy/sdk.ts new file mode 100644 index 0000000..82b5b45 --- /dev/null +++ b/src/chains/starknet/privacy/sdk.ts @@ -0,0 +1,17 @@ +export type PrivacySdk = typeof import("@starkware-libs/starknet-privacy-sdk"); + +let loaded: Promise | undefined; + +/** The privacy SDK is an optional peer dependency, loaded the first time a private operation needs it. */ +export function loadPrivacySdk(): Promise { + loaded ??= import("@starkware-libs/starknet-privacy-sdk").catch((error: unknown) => { + loaded = undefined; + const detail = error instanceof Error && error.message ? ` Original error: ${error.message}` : ""; + throw new Error( + 'kit/starknet: private transfers need the optional peer dependency "@starkware-libs/starknet-privacy-sdk". ' + + "It is published to GitHub Packages, not npmjs: point the @starkware-libs scope at https://npm.pkg.github.com " + + `with a token that has read:packages, then install it. Requires Node 24 or newer.${detail}`, + ); + }); + return loaded; +} diff --git a/src/chains/starknet/privacy/session.test.ts b/src/chains/starknet/privacy/session.test.ts new file mode 100644 index 0000000..466553f --- /dev/null +++ b/src/chains/starknet/privacy/session.test.ts @@ -0,0 +1,136 @@ +import { mockPrivacy, PRIVATE_KEY, STRK } from "./fixtures"; +import { createPrivacySession } from "./session"; +import { deriveViewingKey, viewingPublicKey } from "../viewingKey"; + +const USER_FEE = (token: string) => ({ mode: "sponsored", pool_fee: { paid_by: "user", token } }) as const; + +describe("private session on the in-memory pool", () => { + it("registers, deposits, transfers, reports the balance and withdraws", async () => { + const m = mockPrivacy(); + const session = createPrivacySession(m.init); + + expect(await session.status()).toEqual({ registered: false }); + expect(await session.balance(m.ace)).toBe(0n); + + const registered = await session.register(); + expect(registered.transactionHash).toMatch(/^0x/); + expect(await session.status()).toEqual({ registered: true }); + const key = deriveViewingKey(PRIVATE_KEY, { chainId: m.init.chainId, accountAddress: m.alice, poolAddress: m.pool }); + expect(m.mocknet.pool.get_public_key(m.env.alice.address)).toBe(BigInt(viewingPublicKey(key))); + + await createPrivacySession(m.bobInit).register(); + + await session.deposit(m.ace, 100n); + expect(await session.balance(m.ace)).toBe(100n); + + await session.transfer(m.ace, m.bob, 30n); + expect(await session.balance(m.ace)).toBe(70n); + + await session.withdraw(m.ace, m.alice, 20n); + expect(await session.balance(m.ace)).toBe(50n); + + expect(await createPrivacySession(m.bobInit).balance(m.ace)).toBe(30n); + + expect(m.executed.map((entry) => entry.params.transaction.type)).toEqual([ + "apply_action", + "apply_action", + "invoke_and_apply_action", + "apply_action", + "apply_action", + ]); + expect(m.built.every((entry) => entry.headers["x-paymaster-api-key"] === "key-1")).toBe(true); + }); + + it("sends the approval as a signed relay call and the registration with the sponsor paying", async () => { + const m = mockPrivacy({ fee: USER_FEE("0x1") }); + const session = createPrivacySession(m.init); + await session.register(); + expect(m.built[0]!.params.parameters.fee_mode).toEqual({ mode: "sponsored", pool_fee: { paid_by: "sponsor" } }); + + await session.deposit(m.ace, 100n); + const deposit = m.built[1]!.params; + expect(deposit.transaction.type).toBe("invoke_and_apply_action"); + expect(deposit.parameters.fee_mode).toEqual(USER_FEE("0x1")); + expect(deposit.transaction.invoke.calls).toHaveLength(1); + const signed = m.executed[1]!.params.transaction.invoke; + expect(signed.signature).toHaveLength(2); + expect(signed.user_address).toBe(m.alice); + }); + + it("takes a user-paid pool fee out of the private balance", async () => { + const probe = mockPrivacy(); + const m = mockPrivacy({ fee: USER_FEE(probe.ace), feeAmount: 5n }); + const session = createPrivacySession(m.init); + await session.register(); + await session.deposit(m.ace, 100n); + expect(await session.balance(m.ace)).toBe(95n); + }); + + it("refuses to go on when the pool holds another viewing key for the account", async () => { + const m = mockPrivacy(); + const other = createPrivacySession({ ...m.init, account: { ...m.init.account, spend: { privateKey: () => "0xabc123" } } }); + await other.register(); + + const session = createPrivacySession(m.init); + await expect(session.status()).rejects.toThrow(/different viewing key/); + await expect(session.register()).rejects.toThrow(/different viewing key/); + await expect(session.deposit(m.ace, 1n)).rejects.toThrow(/different viewing key/); + expect(m.executed).toHaveLength(1); + }); + + it("refuses a second registration", async () => { + const m = mockPrivacy(); + const session = createPrivacySession(m.init); + await session.register(); + await expect(session.register()).rejects.toThrow(/already registered/); + }); + + it("leaves registration to the first deposit when the app does not sponsor", async () => { + const m = mockPrivacy({ fee: { mode: "default", gas_token: STRK } }); + const session = createPrivacySession(m.init); + await expect(session.register()).rejects.toThrow(/first deposit registers/); + expect(m.executed).toHaveLength(0); + }); + + it("rejects malformed tokens, recipients and amounts before touching the relay", async () => { + const m = mockPrivacy(); + const session = createPrivacySession(m.init); + await expect(session.deposit("nope", 1n)).rejects.toThrow(/token/); + await expect(session.transfer(m.ace, "0x0", 1n)).rejects.toThrow(/recipient/); + await expect(session.withdraw(m.ace, m.bob, 0n)).rejects.toThrow(/amount/); + expect(m.built).toHaveLength(0); + }); + + it("serialises operations: the second proves after the first lands", async () => { + const m = mockPrivacy(); + const session = createPrivacySession(m.init); + await session.register(); + const order: string[] = []; + const first = session.deposit(m.ace, 10n).then(() => order.push("first")); + const second = session.deposit(m.ace, 20n).then(() => order.push("second")); + await Promise.all([first, second]); + expect(order).toEqual(["first", "second"]); + expect(await session.balance(m.ace)).toBe(30n); + }); + + it("waits for the previous transaction to age before proving", async () => { + const m = mockPrivacy(); + const session = createPrivacySession(m.init); + await session.register(); + m.init.deps!.chain!.confirm = async () => 995; + const pending = session.deposit(m.ace, 10n); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(m.executed).toHaveLength(1); + m.setHead(1010); + await pending; + expect(m.executed).toHaveLength(2); + }); + + it("never reaches a fee recipient the app did not allow", async () => { + const probe = mockPrivacy(); + const m = mockPrivacy({ fee: USER_FEE(probe.ace), feeAmount: 5n, quotedRecipient: "0xbad" }); + const session = createPrivacySession(m.init); + await expect(session.deposit(m.ace, 10n)).rejects.toThrow(/does not allow/); + expect(m.executed).toHaveLength(0); + }); +}); diff --git a/src/chains/starknet/privacy/session.ts b/src/chains/starknet/privacy/session.ts new file mode 100644 index 0000000..dc5a0b7 --- /dev/null +++ b/src/chains/starknet/privacy/session.ts @@ -0,0 +1,379 @@ +import { CallData, Signer, hash, num, type Call, type TypedData } from "starknet"; +import type { + PrivateTransfersBuilder, + ProofProviderConfig, + TokenOperationsBuilder, +} from "@starkware-libs/starknet-privacy-sdk"; +import { viewingPublicKey, deriveViewingKey } from "../viewingKey"; +import { + DEFAULT_POLL, + PROOF_BASE_BLOCK_DEPTH, + assertProofFresh, + provableBlock, + rpcChain, + type PollOptions, + type PrivacyChain, +} from "./chain"; +import type { PrivacyFeeMode, StarknetPrivacyConfig } from "./config"; +import { PrivacyRelay, type FeeAction, type RelayCall } from "./relay"; +import { loadPrivacySdk, type PrivacySdk } from "./sdk"; + +export type PrivacyRequirement = "register" | "setup-channel" | "setup-token" | "ready"; + +export interface PrivacyStatus { + /** The pool holds a viewing key for this account. */ + registered: boolean; + /** What the account still needs before it can use `token`. Only present when a token was asked about. */ + requirement?: PrivacyRequirement; +} + +export interface PrivacySubmission { + transactionHash: string; +} + +/** The only operations a private account offers. No method returns a key, a note or a witness. */ +export interface PrivacyOperations { + status(token?: string): Promise; + balance(token: string): Promise; + register(): Promise; + deposit(token: string, amount: bigint): Promise; + transfer(token: string, recipient: string, amount: bigint): Promise; + withdraw(token: string, recipient: string, amount: bigint): Promise; +} + +type Sdk = PrivacySdk; +type TransfersClient = ReturnType; +type Registry = ReturnType; +type CreateParams = Parameters[0]; + +/** Replaceable pieces, for tests that run against the SDK's in-memory pool. */ +export interface PrivacySessionDeps { + chain?: PrivacyChain; + sdk?: PrivacySdk; + provingProvider?: CreateParams["provingProvider"]; + discoveryProvider?: CreateParams["discoveryProvider"]; + proofInvocationFactory?: CreateParams["proofInvocationFactory"]; + fetch?: typeof fetch; + poll?: PollOptions; +} + +export interface PrivacySessionInit { + config: StarknetPrivacyConfig; + /** Felt of the chain the account lives on. */ + chainId: string; + /** RPC used when the config names none. */ + rpcUrl: string; + account: { address: string; spend: { privateKey(): string } }; + paymasterApiKey: () => string; + deps?: PrivacySessionDeps; +} + +const STRK = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; +const SPONSOR_FEE: PrivacyFeeMode = { mode: "sponsored", pool_fee: { paid_by: "sponsor" } }; +const REQUIREMENTS: PrivacyRequirement[] = ["register", "setup-channel", "setup-token", "ready"]; + +interface Operation { + fee: PrivacyFeeMode; + invoke?: { token: string; amount: bigint }; + autoRegister?: boolean; + compose(builder: PrivateTransfersBuilder, fee: FeeAction | null): void; +} + +/** + * One account's private state in one pool. It lives only where the account key + * lives: the viewing key, the discovered notes and the witnesses are held here + * and never returned. + */ +export class PrivacySession implements PrivacyOperations { + private readonly config: StarknetPrivacyConfig; + private readonly address: string; + private readonly chain: PrivacyChain; + private readonly poll: PollOptions; + private readonly relay: PrivacyRelay; + private viewingKey?: string; + private transfers?: Promise; + private registry?: Registry; + private lastTransaction?: string; + private queue: Promise = Promise.resolve(); + + constructor(private readonly init: PrivacySessionInit) { + this.config = init.config; + this.address = init.account.address; + this.chain = init.deps?.chain ?? rpcChain(init.config.rpcUrl ?? init.rpcUrl); + this.poll = init.deps?.poll ?? DEFAULT_POLL; + this.relay = new PrivacyRelay({ + url: init.config.relay.url, + apiKey: init.paymasterApiKey, + maxFee: BigInt(init.config.relay.maxFee), + allowedFeeRecipients: init.config.relay.allowedFeeRecipients, + publishedFee: async () => { + const [fee] = await this.chain.call(this.config.pool, "get_fee_amount"); + return BigInt(fee ?? "0"); + }, + publishedFeeToken: STRK, + ...(init.deps?.fetch ? { fetch: init.deps.fetch } : {}), + }); + } + + async status(token?: string): Promise { + return this.scrubbed(async () => { + const registered = await this.registered(); + if (token === undefined) return { registered }; + const transfers = await this.client(); + const requirement = await transfers.discoverRequirement(this.address, requireAddress(token, "token")); + return { registered, requirement: REQUIREMENTS[requirement] ?? "ready" }; + }); + } + + async balance(token: string): Promise { + return this.scrubbed(async () => { + const id = BigInt(requireAddress(token, "token")); + if (!(await this.registered())) return 0n; + const transfers = await this.client(); + const { notes } = await transfers.discoverNotes({ tokens: [id] }); + return (notes.get(id) ?? []).reduce((total, note) => total + note.amount, 0n); + }); + } + + async register(): Promise { + return this.run(async () => { + if (await this.registered()) throw new Error("kit/starknet: this account is already registered in the pool"); + if (this.config.relay.fee.mode !== "sponsored") { + throw new Error("kit/starknet: this app does not sponsor private transfers, so the first deposit registers the account"); + } + return { fee: SPONSOR_FEE, compose: (builder) => void builder.register() }; + }); + } + + async deposit(token: string, amount: bigint): Promise { + const id = requireAddress(token, "token"); + requireAmount(amount); + return this.run(async () => ({ + fee: this.config.relay.fee, + invoke: { token: id, amount }, + autoRegister: true, + compose: (builder, fee) => + this.withToken(builder, id, fee, (t) => t.deposit({ amount }).surplusTo(this.address, false)), + })); + } + + async transfer(token: string, recipient: string, amount: bigint): Promise { + const id = requireAddress(token, "token"); + const to = requireAddress(recipient, "recipient"); + requireAmount(amount); + return this.run(async () => ({ + fee: this.config.relay.fee, + compose: (builder, fee) => + this.withToken(builder, id, fee, (t) => t.transfer({ recipient: to, amount }).surplusTo(this.address, false)), + })); + } + + async withdraw(token: string, recipient: string, amount: bigint): Promise { + const id = requireAddress(token, "token"); + const to = requireAddress(recipient, "recipient"); + requireAmount(amount); + return this.run(async () => ({ + fee: this.config.relay.fee, + compose: (builder, fee) => + this.withToken(builder, id, fee, (t) => t.withdraw({ recipient: to, amount }).surplusTo(this.address, false)), + })); + } + + private withToken( + builder: PrivateTransfersBuilder, + token: string, + fee: FeeAction | null, + operate: (t: TokenOperationsBuilder) => unknown, + ): void { + const charge = fee && fee.amount > 0n ? fee : null; + const sameToken = charge !== null && BigInt(charge.token) === BigInt(token); + builder.with(token, (t) => { + operate(t); + if (sameToken) t.withdraw({ recipient: charge.recipient, amount: charge.amount }); + }); + if (charge && !sameToken) { + builder.with(charge.token, (t) => t.withdraw({ recipient: charge.recipient, amount: charge.amount })); + } + } + + private run(plan: () => Promise): Promise { + const result = this.queue.then(() => this.scrubbed(async () => this.submit(await plan()))); + this.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async submit(operation: Operation): Promise { + await this.registered(); + const transfers = await this.client(); + const invoke = operation.invoke ? this.approval(operation.invoke) : undefined; + const quote = await this.relay.quote(this.config.pool, operation.fee, invoke); + + let approval: { userAddress: string; typedData: TypedData; signature: Awaited> } | undefined; + if (invoke && quote.typedData) { + approval = { + userAddress: this.address, + typedData: quote.typedData, + signature: await new Signer(this.init.account.spend.privateKey()).signMessage(quote.typedData, this.address), + }; + } + + const provingBlockId = await this.provingBlock(); + this.registry ??= (await this.sdk()).createEmptyRegistry(); + this.registry.notes.clear(); + const builder = transfers.build({ + autoSelectNotes: "naive", + autoSetup: true, + autoDiscover: { notes: "refresh", channels: "refresh" }, + ...(operation.autoRegister ? { autoRegister: true } : {}), + registry: this.registry, + provingBlockId, + }); + operation.compose(builder, quote.feeAction); + + try { + const { callAndProof, registry } = await builder.execute(); + this.registry = registry; + await assertProofFresh(this.chain, this.config.pool, callAndProof.proof.proofFacts); + const submission = await this.relay.execute( + toRelayCall(callAndProof.call), + { data: callAndProof.proof.data, proofFacts: callAndProof.proof.proofFacts }, + quote.parameters, + approval, + ); + this.lastTransaction = submission.transactionHash; + return submission; + } catch (error) { + transfers.invalidateProofNonceCache(); + throw error; + } + } + + private approval(spec: { token: string; amount: bigint }): { userAddress: string; chainId: string; calls: RelayCall[] } { + const low = spec.amount & ((1n << 128n) - 1n); + const high = spec.amount >> 128n; + const call: Call = { + contractAddress: spec.token, + entrypoint: "approve", + calldata: [this.config.pool, `0x${low.toString(16)}`, `0x${high.toString(16)}`], + }; + return { userAddress: this.address, chainId: this.init.chainId, calls: [toRelayCall(call)] }; + } + + private async provingBlock(): Promise { + const previous = this.lastTransaction ? await this.chain.confirm(this.lastTransaction, this.poll) : -1; + return provableBlock(this.chain, previous, this.poll, PROOF_BASE_BLOCK_DEPTH); + } + + /** Reads the registered key and refuses to go on when it is not the one this account derives: a replacement is never registered. */ + private async registered(): Promise { + const mine = BigInt(viewingPublicKey(this.derived())); + const [stored] = await this.chain.call(this.config.pool, "get_public_key", [this.address]); + const key = BigInt(stored ?? "0"); + if (key === 0n) return false; + if (key !== mine) { + throw new Error( + "kit/starknet: this account is registered in the pool with a different viewing key. Cavos will not register a replacement; the private balance stays reachable only with the original key.", + ); + } + return true; + } + + private derived(): string { + this.viewingKey ??= deriveViewingKey(this.init.account.spend.privateKey(), { + chainId: this.init.chainId, + accountAddress: this.address, + poolAddress: this.config.pool, + }); + return this.viewingKey; + } + + private sdk(): Promise { + return this.init.deps?.sdk ? Promise.resolve(this.init.deps.sdk) : loadPrivacySdk(); + } + + private client(): Promise { + this.transfers ??= this.createClient().catch((error: unknown) => { + this.transfers = undefined; + throw error; + }); + return this.transfers; + } + + private async createClient(): Promise { + const sdk = await this.sdk(); + const deps = this.init.deps; + const ohttp = this.config.ohttp ?? true; + const chainId = this.init.chainId as ProofProviderConfig["chainId"]; + const params: CreateParams = { + account: { + address: this.address, + signer: new Signer(this.init.account.spend.privateKey()) as unknown as CreateParams["account"]["signer"], + }, + viewingKeyProvider: { getViewingKey: async () => this.derived() }, + provingProvider: deps?.provingProvider ?? { + url: this.config.prover, + chainId, + nodeUrl: this.config.rpcUrl ?? this.init.rpcUrl, + ...(ohttp ? { ohttp: true } : {}), + }, + discoveryProvider: + deps?.discoveryProvider ?? + new sdk.IndexerDiscoveryProvider(this.config.discovery, this.config.pool, ohttp ? { ohttp: true } : {}), + poolContractAddress: this.config.pool, + ...(deps?.proofInvocationFactory ? { proofInvocationFactory: deps.proofInvocationFactory } : {}), + }; + return sdk.createPrivateTransfers(params); + } + + private async scrubbed(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + throw this.redact(error); + } + } + + private redact(error: unknown): Error { + const secrets = [this.viewingKey, this.init.account.spend.privateKey()].filter((value): value is string => Boolean(value)); + let message = error instanceof Error ? error.message : String(error); + for (const secret of secrets) { + const bare = BigInt(secret).toString(16); + message = message.split(secret).join("[redacted]").replace(new RegExp(bare, "gi"), "[redacted]"); + } + return new Error(message); + } +} + +function toRelayCall(call: Call): RelayCall { + return { + to: call.contractAddress, + selector: /^0x[0-9a-f]+$/i.test(call.entrypoint) ? call.entrypoint : hash.getSelectorFromName(call.entrypoint), + calldata: CallData.compile(call.calldata ?? []).map((felt) => num.toHex(felt)), + }; +} + +function requireAddress(value: string, field: string): string { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{1,64}$/.test(value) || BigInt(value) === 0n) { + throw new Error(`kit/starknet: ${field} must be a 0x-prefixed Starknet address`); + } + return value; +} + +function requireAmount(amount: bigint): void { + if (typeof amount !== "bigint" || amount <= 0n) throw new Error("kit/starknet: amount must be a positive number of base units"); +} + +export function createPrivacySession(init: PrivacySessionInit): PrivacySession { + return new PrivacySession(init); +} + +/** Stands in for the operations when nothing can run them, so every call says why. */ +export function unavailablePrivacy(reason: string): PrivacyOperations { + const refuse = async (): Promise => { + throw new Error(reason); + }; + return { status: refuse, balance: refuse, register: refuse, deposit: refuse, transfer: refuse, withdraw: refuse }; +} diff --git a/src/chains/starknet/privacyConnect.test.ts b/src/chains/starknet/privacyConnect.test.ts new file mode 100644 index 0000000..3362b12 --- /dev/null +++ b/src/chains/starknet/privacyConnect.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { RpcProvider } from "starknet"; +import { LocalDeviceUnwrapKey } from "../stellar/DeviceUnwrapKey"; +import { deviceFactorFromUnwrapKey } from "../../secret/factor"; +import type { WrapStore } from "../../secret/DeviceSecret"; +import type { WrappedDEK } from "../../secret/wrap"; +import { InMemoryWalletRegistry } from "../../registry/WalletRegistry"; +import type { VaultClient } from "../../vault/VaultClient"; +import { CavosStarknet } from "./CavosStarknet"; +import { STARKNET_NETWORKS } from "./constants"; +import * as session from "./privacy/session"; +import type { PrivacyOperations } from "./privacy/session"; +import type { StarknetPrivacyConfig } from "./privacy/config"; + +class MemoryStore implements WrapStore { + private rows = new Map(); + async get(userId: string) { + return this.rows.get(userId) ?? null; + } + async put(userId: string, wrap: WrappedDEK) { + this.rows.set(userId, wrap); + } + async clear(userId: string) { + this.rows.delete(userId); + } +} + +const privacy: StarknetPrivacyConfig = { + pool: "0x123", + prover: "https://prover.example.com", + discovery: "https://discovery.example.com", + relay: { + url: "https://relay.example.com", + fee: { mode: "sponsored", pool_fee: { paid_by: "sponsor" } }, + maxFee: "1000", + allowedFeeRecipients: ["0x75a1"], + }, +}; + +const connect = (extra: Record = {}) => + CavosStarknet.connect({ + network: "sepolia", + identity: { userId: "u-privacy" }, + appSalt: "salt", + registry: new InMemoryWalletRegistry(), + paymasterApiKey: "page-key", + factor: deviceFactorFromUnwrapKey(LocalDeviceUnwrapKey.generate()), + wrapStore: new MemoryStore(), + ...extra, + }); + +describe("CavosStarknet.privacy", () => { + afterEach(() => jest.restoreAllMocks()); + + it("runs the session locally with the connect options when the key is in the page", async () => { + jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); + const operations: PrivacyOperations = { + status: jest.fn(async () => ({ registered: true })), + balance: jest.fn(async () => 7n), + register: jest.fn(async () => ({ transactionHash: "0x1" })), + deposit: jest.fn(async () => ({ transactionHash: "0x2" })), + transfer: jest.fn(async () => ({ transactionHash: "0x3" })), + withdraw: jest.fn(async () => ({ transactionHash: "0x4" })), + }; + const create = jest.spyOn(session, "createPrivacySession").mockReturnValue(operations as session.PrivacySession); + + const wallet = await connect({ privacy }); + + const init = create.mock.calls[0]![0]; + expect(init.config.pool).toBe("0x123"); + expect(init.chainId).toBe(STARKNET_NETWORKS.sepolia.chainId); + expect(init.account.address).toBe(wallet.address); + expect(init.paymasterApiKey()).toBe("page-key"); + expect(await wallet.privacy.balance("0x1")).toBe(7n); + expect(await wallet.privacy.deposit("0x1", 5n)).toEqual({ transactionHash: "0x2" }); + }); + + it("says what is missing when the page gives no settings", async () => { + jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); + const wallet = await connect(); + await expect(wallet.privacy.status()).rejects.toThrow(/`privacy` in the connect options/); + await expect(wallet.privacy.transfer("0x1", "0x2", 1n)).rejects.toThrow(/`privacy` in the connect options/); + }); + + it("validates the settings when connecting", async () => { + jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); + await expect(connect({ privacy: { ...privacy, pool: "nope" } })).rejects.toThrow(/privacy\.pool/); + }); + + it("refuses page settings when the key is in the vault", async () => { + const vault = { connectStarknet: jest.fn() } as unknown as VaultClient; + await expect(connect({ vault, privacy })).rejects.toThrow(/Cavos dashboard/); + expect(vault.connectStarknet).not.toHaveBeenCalled(); + }); + + it("uses the vault's operations and hands it the paymaster key", async () => { + jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); + const operations = { status: jest.fn(async () => ({ registered: false })) } as unknown as PrivacyOperations; + const connectStarknet = jest.fn(async () => ({ + address: "0xabc", + isNewAccount: false, + signer: null, + passkey: false, + privacy: operations, + })); + const wallet = await connect({ vault: { connectStarknet } as unknown as VaultClient, factor: undefined, wrapStore: undefined }); + expect(connectStarknet.mock.calls[0]![1]).toBe("page-key"); + expect(await wallet.privacy.status()).toEqual({ registered: false }); + }); +}); diff --git a/src/chains/starknet/viewingKey.test.ts b/src/chains/starknet/viewingKey.test.ts new file mode 100644 index 0000000..0c5b915 --- /dev/null +++ b/src/chains/starknet/viewingKey.test.ts @@ -0,0 +1,56 @@ +import { deriveViewingKey, viewingPublicKey, type ViewingKeyContext } from "./viewingKey"; + +const CONTEXT: ViewingKeyContext = { + chainId: "0x534e5f5345504f4c4941", + accountAddress: "0x1234", + poolAddress: "0x5678", +}; + +const VECTORS = [ + { + id: "primary", + privateKey: "0x1", + viewingKey: "0x12eb8ad5ccd790c7f680a4364db37673265f91147173130bed719a511a36bcb", + publicKeyX: "0x435ada564d3bb1c5ac7caac8aa5fdc7dfa5de1aea83ff660099ea619a08c7a5", + }, + { + id: "rejection-sampling", + privateKey: "0x5", + viewingKey: "0x1ad67c997ffb3359196403b585305625f57635ce102dff935f1066031289bf0", + publicKeyX: "0x4366caa68f40c9467a805b2646bcca3ac60c320c165564f69bc2de4b668e7ed", + }, +]; + +describe("SNIP-44 account-leaf-v1", () => { + it.each(VECTORS)("matches the $id test vector", ({ privateKey, viewingKey, publicKeyX }) => { + const derived = deriveViewingKey(privateKey, CONTEXT); + expect(derived).toBe(viewingKey); + expect(viewingPublicKey(derived)).toBe(publicKeyX); + }); + + it("binds the key to the chain, account and pool", () => { + const base = deriveViewingKey("0x1", CONTEXT); + expect(deriveViewingKey("0x1", { ...CONTEXT, chainId: "0x534e5f4d41494e" })).not.toBe(base); + expect(deriveViewingKey("0x1", { ...CONTEXT, accountAddress: "0x1235" })).not.toBe(base); + expect(deriveViewingKey("0x1", { ...CONTEXT, poolAddress: "0x5679" })).not.toBe(base); + }); + + it("reads felts by value, not by spelling", () => { + expect(deriveViewingKey("0x1", { ...CONTEXT, accountAddress: "0x0000001234" })).toBe( + deriveViewingKey("0x1", CONTEXT), + ); + }); + + it("refuses a key outside the Stark scalar range", () => { + expect(() => deriveViewingKey("0x0", CONTEXT)).toThrow("not a Stark scalar"); + expect(() => + deriveViewingKey("0x0800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f", CONTEXT), + ).toThrow("not a Stark scalar"); + }); + + it("refuses a context value wider than its field", () => { + expect(() => deriveViewingKey("0x1", { ...CONTEXT, poolAddress: `0x1${"0".repeat(64)}` })).toThrow( + "does not fit in 32 bytes", + ); + }); +}); diff --git a/src/chains/starknet/viewingKey.ts b/src/chains/starknet/viewingKey.ts new file mode 100644 index 0000000..34adce4 --- /dev/null +++ b/src/chains/starknet/viewingKey.ts @@ -0,0 +1,76 @@ +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha256"; +import { ec, num } from "starknet"; +import { bytesToBigInt, utf8ToBytes } from "../../crypto/encoding"; + +/** The chain, account and STRK20 pool a viewing key is bound to (SNIP-44 `DerivationContext`, `key_index` 0). */ +export interface ViewingKeyContext { + chainId: string; + accountAddress: string; + poolAddress: string; +} + +const ORDER = ec.starkCurve.CURVE.n; +const HALF_ORDER = ORDER / 2n; +const LIMIT = 2n ** 256n - (2n ** 256n % ORDER); +const DOMAIN = utf8ToBytes("STRK20_ACCOUNT_LEAF_V1"); +const CONTEXT_VERSION = 1n; +const KEY_INDEX = 0n; + +/** + * STRK20 viewing key of a Stark account key, per SNIP-44 `account-leaf-v1`: + * HMAC-SHA256 keyed by the private scalar, rejection-sampled into `[1, n/2)`. + * The same key and context give the same viewing key on every device. + * + * The result is as sensitive as the private state it decrypts. It must never + * leave the vault. + */ +export function deriveViewingKey(privateKey: string, context: ViewingKeyContext): string { + const scalar = BigInt(privateKey); + if (scalar < 1n || scalar >= ORDER) throw new Error("kit/starknet: the account key is not a Stark scalar"); + const key = i2osp(scalar, 32); + const contextBytes = concat( + i2osp(CONTEXT_VERSION, 4), + i2osp(BigInt(context.chainId), 32), + i2osp(BigInt(context.accountAddress), 32), + i2osp(BigInt(context.poolAddress), 32), + i2osp(KEY_INDEX, 16), + ); + for (let counter = 0n; counter < 2n ** 32n; counter++) { + const candidate = bytesToBigInt(hmac(sha256, key, concat(DOMAIN, contextBytes, i2osp(counter, 4)))); + if (candidate >= LIMIT) continue; + const reduced = candidate % ORDER; + if (reduced === 0n) continue; + const folded = reduced < ORDER - reduced ? reduced : ORDER - reduced; + if (folded < HALF_ORDER) return num.toHex(folded); + } + throw new Error("kit/starknet: viewing key derivation exhausted its counter"); +} + +/** Public viewing key, the x-coordinate a STRK20 pool stores for a registered account. */ +export function viewingPublicKey(viewingKey: string): string { + return ec.starkCurve.getStarkKey(viewingKey); +} + +function i2osp(value: bigint, length: number): Uint8Array { + if (value < 0n || value >> BigInt(8 * length) !== 0n) { + throw new Error(`kit/starknet: value does not fit in ${length} bytes`); + } + const out = new Uint8Array(length); + let rest = value; + for (let i = length - 1; i >= 0; i--) { + out[i] = Number(rest & 0xffn); + rest >>= 8n; + } + return out; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} diff --git a/src/index.ts b/src/index.ts index 6ad030d..0ce043d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,14 @@ export type { } from "./chains/ChainAdapter"; export { CavosStarknet } from "./chains/starknet/CavosStarknet"; export type { ConnectStarknetOptions } from "./chains/starknet/CavosStarknet"; +export { parseStarknetPrivacyConfig } from "./chains/starknet/privacy/config"; +export type { PrivacyFeeMode, PrivacyRelayConfig, StarknetPrivacyConfig } from "./chains/starknet/privacy/config"; +export type { + PrivacyOperations, + PrivacyRequirement, + PrivacyStatus, + PrivacySubmission, +} from "./chains/starknet/privacy/session"; export { STARKNET_NETWORKS, CAVOS_PAYMASTER_URL, OZ_ACCOUNT_CLASS_HASH } from "./chains/starknet/constants"; export type { StarknetNetwork } from "./chains/starknet/constants"; export { diff --git a/src/starknet.ts b/src/starknet.ts index b43fc7b..957f4db 100644 --- a/src/starknet.ts +++ b/src/starknet.ts @@ -8,3 +8,15 @@ export { starknetAddressFromPublicKey, starknetAddressFromSeed, } from "./chains/starknet/keys"; +export { parseStarknetPrivacyConfig } from "./chains/starknet/privacy/config"; +export type { + PrivacyFeeMode, + PrivacyRelayConfig, + StarknetPrivacyConfig, +} from "./chains/starknet/privacy/config"; +export type { + PrivacyOperations, + PrivacyRequirement, + PrivacyStatus, + PrivacySubmission, +} from "./chains/starknet/privacy/session"; diff --git a/src/vault/VaultClient.ts b/src/vault/VaultClient.ts index eda2e18..2133098 100644 --- a/src/vault/VaultClient.ts +++ b/src/vault/VaultClient.ts @@ -12,6 +12,7 @@ import { type TypedData, } from "starknet"; import type { AuthProvider, Identity } from "../auth/AuthProvider"; +import type { PrivacyOperations } from "../chains/starknet/privacy/session"; import type { ControlKey } from "../chains/stellar/WebCryptoControlKey"; import type { SocialRecoveryCredential } from "../recovery/SocialRecoveryCredential"; import type { Ed25519SpendSigner } from "../signer/Ed25519SpendSigner"; @@ -109,11 +110,16 @@ export class VaultClient { }; } - async connectStarknet(params: Omit): Promise<{ + /** `paymasterApiKey` is what the vault hands the relay for private transfers. */ + async connectStarknet( + params: Omit, + paymasterApiKey?: string, + ): Promise<{ address: string; isNewAccount: boolean; signer: SignerInterface | null; passkey: boolean; + privacy: PrivacyOperations | null; }> { const result = await this.call("connect", { ...params, chain: "starknet" }); return { @@ -121,6 +127,7 @@ export class VaultClient { isNewAccount: result.isNewAccount, signer: this.starknetSigner(result), passkey: result.passkey === true, + privacy: result.publicKey ? this.starknetPrivacy(result.handle, paymasterApiKey ?? "") : null, }; } @@ -143,6 +150,19 @@ export class VaultClient { }; } + private starknetPrivacy(handle: string, paymasterApiKey: string): PrivacyOperations { + return { + status: (token) => this.call("privacyStatus", { handle, ...(token === undefined ? {} : { token }) }), + balance: async (token) => BigInt(await this.call("privacyBalance", { handle, token })), + register: () => this.call("privacyRegister", { handle, paymasterApiKey }), + deposit: (token, amount) => this.call("privacyDeposit", { handle, token, amount: amount.toString(), paymasterApiKey }), + transfer: (token, recipient, amount) => + this.call("privacyTransfer", { handle, token, recipient, amount: amount.toString(), paymasterApiKey }), + withdraw: (token, recipient, amount) => + this.call("privacyWithdraw", { handle, token, recipient, amount: amount.toString(), paymasterApiKey }), + }; + } + private starknetSigner({ handle, publicKey }: ConnectResult): SignerInterface | null { if (!publicKey) return null; return new VaultStarknetSigner( diff --git a/src/vault/host.ts b/src/vault/host.ts index e27cb27..c2862e2 100644 --- a/src/vault/host.ts +++ b/src/vault/host.ts @@ -1,5 +1,8 @@ import type { ControlKey } from "../chains/stellar/WebCryptoControlKey"; +import { STARKNET_NETWORKS, type StarknetNetwork } from "../chains/starknet/constants"; import { resolveNativeStarknet, type StarknetSpend } from "../chains/starknet/nativeConnect"; +import { parseStarknetPrivacyConfig, type StarknetPrivacyConfig } from "../chains/starknet/privacy/config"; +import { PrivacySession, type PrivacySessionDeps } from "../chains/starknet/privacy/session"; import { resolveNativeSolana } from "../chains/solana/nativeConnect"; import { resolveNativeStellar } from "../chains/stellar/nativeConnect"; import { DEFAULT_SOCIAL_RECOVERY_ATTESTATION } from "../recovery/attestationDefaults"; @@ -17,7 +20,14 @@ import { solanaNetworkName, starknetNetworkName, stellarNetworkName, stellarNetw import { VersionedMessage } from "@solana/web3.js"; import { Signer, stark, type Signature } from "starknet"; import { xdr } from "@stellar/stellar-sdk"; -import { isQueryVersion, parseStarknetCalls, parseStarknetTypedData, selectorOf } from "./parseStarknet"; +import { + isQueryVersion, + parseStarknetCalls, + parseStarknetPrivacy, + parseStarknetTypedData, + selectorOf, + type StarknetPrivacyOperation, +} from "./parseStarknet"; import { idbLedger, type SpendLedger } from "./ledger"; import { VaultModal } from "./modal"; import { parseSolanaMessage } from "./parseSolana"; @@ -50,6 +60,9 @@ export interface VaultHandlerDeps { resolveStellar?: (input: NativeBase) => Promise<{ address: string; control?: ControlKey; isNewAccount: boolean }>; resolveStarknet?: (input: NativeBase) => Promise<{ address: string; spend: StarknetSpend | null; isNewAccount: boolean }>; solanaNetwork?: (recentBlockhash: string) => Promise; + /** Private transfer settings of the app for a network ("sepolia", "mainnet"), from the Cavos dashboard. Throws on an invalid one. */ + privacy?: (network: string) => StarknetPrivacyConfig | undefined; + privacyDeps?: PrivacySessionDeps; } interface Account { @@ -59,6 +72,8 @@ interface Account { solana?: Ed25519SpendSigner; stellar?: ControlKey; starknet?: StarknetSpend; + privacy?: PrivacySession; + paymasterApiKey?: string; } export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultRequest) => Promise { @@ -194,6 +209,43 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque }); } + function privacySession(acct: Account, paymasterApiKey: string | undefined): { session: PrivacySession; config: StarknetPrivacyConfig; chainId: string } { + const spend = requireKey(acct.starknet); + const network = STARKNET_NETWORKS[acct.network as StarknetNetwork]; + if (!network) throw new Error("kit/vault: private transfers are only available on Starknet"); + const config = deps.privacy?.(acct.network); + if (!config) { + throw new Error(`kit/vault: add private transfer settings for ${acct.network} to this app in the Cavos dashboard`); + } + if (paymasterApiKey !== undefined) acct.paymasterApiKey = paymasterApiKey; + acct.privacy ??= new PrivacySession({ + config, + chainId: network.chainId, + rpcUrl: network.rpcUrl, + account: { address: acct.address, spend }, + paymasterApiKey: () => { + if (!acct.paymasterApiKey) throw new Error("kit/vault: private transfers need the paymaster API key"); + return acct.paymasterApiKey; + }, + ...(deps.privacyDeps ? { deps: deps.privacyDeps } : {}), + }); + return { session: acct.privacy, config, chainId: network.chainId }; + } + + async function privacyOperation( + params: { handle: string; token: string; amount: string; paymasterApiKey: string; recipient?: string }, + kind: StarknetPrivacyOperation["kind"], + run: (session: PrivacySession, amount: bigint) => Promise, + ): Promise { + const acct = account(params.handle); + const { session, config, chainId } = privacySession(acct, params.paymasterApiKey); + const amount = baseUnits(params.amount); + const to = kind === "deposit" ? config.pool : params.recipient; + const parsed = parseStarknetPrivacy({ kind, token: address(params.token, "token"), amount, to: address(to, "recipient") }); + const start = await authorizeAndSign(acct, parsed, async () => starknetNetworkName(chainId), async () => () => run(session, amount)); + return start(); + } + const methods: { [M in VaultMethod]: (params: VaultParams) => Promise } = { connect, async signSolanaTransaction({ handle, message }) { @@ -246,6 +298,24 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque ); return authorizeAndSign(acct, parsed, async () => network, sign); }, + async privacyStatus({ handle, token }) { + return privacySession(account(handle), undefined).session.status(token); + }, + async privacyBalance({ handle, token }) { + return (await privacySession(account(handle), undefined).session.balance(token)).toString(); + }, + async privacyRegister({ handle, paymasterApiKey }) { + return privacySession(account(handle), paymasterApiKey).session.register(); + }, + async privacyDeposit(params) { + return privacyOperation(params, "deposit", (session, amount) => session.deposit(params.token, amount)); + }, + async privacyTransfer(params) { + return privacyOperation(params, "transfer", (session, amount) => session.transfer(params.token, params.recipient, amount)); + }, + async privacyWithdraw(params) { + return privacyOperation(params, "withdraw", (session, amount) => session.withdraw(params.token, params.recipient, amount)); + }, async forget({ userId, appSalt }) { // Only lets go of the unlocked keys. Deleting stored ones is not the app's // call: without a recovery factor it would destroy the wallet. @@ -282,6 +352,20 @@ function stellarAuthNetworkId(preimage: Uint8Array): Uint8Array { return new Uint8Array(xdr.HashIdPreimage.fromXDR(Buffer.from(preimage)).sorobanAuthorization().networkId()); } +function baseUnits(value: unknown): bigint { + if (typeof value !== "string" || !/^\d+$/.test(value) || BigInt(value) === 0n) { + throw new Error("kit/vault: amount must be a positive decimal string of base units"); + } + return BigInt(value); +} + +function address(value: unknown, field: string): string { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{1,64}$/.test(value) || BigInt(value) === 0n) { + throw new Error(`kit/vault: ${field} must be a 0x-prefixed Starknet address`); + } + return value; +} + function toFelts(signature: Signature): string[] { return stark.formatSignature(signature); } @@ -342,6 +426,7 @@ export function startVaultHost(opts: VaultHostOptions): void { backendUrl: opts.backendUrl, attestation: opts.attestation, policy: app.policy, + privacy: app.privacy, ledger: idbLedger(), confirm: (lines, context) => modal.review(lines, context.network), passkey: (user) => modal.passkey(user), @@ -357,20 +442,36 @@ interface AppSettings { /** Null when the app registered none. */ origins: string[] | null; policy: VaultPolicy; + privacy: (network: string) => StarknetPrivacyConfig | undefined; } +const NO_PRIVACY = () => undefined; + async function loadApp(policyUrl: string, appId: string): Promise { - if (!appId) return { origins: [], policy: STRICT_POLICY }; + if (!appId) return { origins: [], policy: STRICT_POLICY, privacy: NO_PRIVACY }; try { const res = await fetch(`${policyUrl}?app_id=${encodeURIComponent(appId)}`); - if (!res.ok) return { origins: [], policy: STRICT_POLICY }; - const body = (await res.json()) as Partial; - return { origins: body.origins ?? null, policy: body.policy ?? STRICT_POLICY }; + if (!res.ok) return { origins: [], policy: STRICT_POLICY, privacy: NO_PRIVACY }; + const body = (await res.json()) as { origins?: string[] | null; policy?: VaultPolicy; privacy?: unknown }; + return { origins: body.origins ?? null, policy: body.policy ?? STRICT_POLICY, privacy: privacySettings(body.privacy) }; } catch { - return { origins: [], policy: STRICT_POLICY }; + return { origins: [], policy: STRICT_POLICY, privacy: NO_PRIVACY }; } } +/** The dashboard keys private transfer settings by Starknet network name. */ +function privacySettings(raw: unknown): (network: string) => StarknetPrivacyConfig | undefined { + const byNetwork = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + return (network) => { + if (!Object.prototype.hasOwnProperty.call(byNetwork, network)) return undefined; + try { + return parseStarknetPrivacyConfig(byNetwork[network]); + } catch (error) { + throw new Error(`kit/vault: the private transfer settings for ${network} are invalid. ${error instanceof Error ? error.message : ""}`); + } + }; +} + /** An app with no registered origins cannot embed the vault at all. */ function originAllowed(origins: string[] | null, origin: string): boolean { return origins !== null && origins.includes(origin); diff --git a/src/vault/parseStarknet.ts b/src/vault/parseStarknet.ts index ae0479e..b16acc7 100644 --- a/src/vault/parseStarknet.ts +++ b/src/vault/parseStarknet.ts @@ -15,6 +15,10 @@ const TOKENS = new Map([ [BigInt("0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8"), { symbol: "USDC", decimals: 6 }], ]); +export function knownStarknetToken(address: string): { symbol: string; decimals: number } | undefined { + return TOKENS.get(BigInt(address)); +} + const TRANSFER = BigInt(hash.getSelectorFromName("transfer")); const APPROVALS = new Set(["approve", "increase_allowance", "increaseAllowance"].map((name) => BigInt(hash.getSelectorFromName(name)))); @@ -49,6 +53,40 @@ export function parseStarknetCalls(calls: StarknetCall[], account: string): Pars return tx; } +export interface StarknetPrivacyOperation { + kind: "deposit" | "transfer" | "withdraw"; + token: string; + amount: bigint; + /** The pool for a deposit, the receiver otherwise. */ + to: string; +} + +/** + * What the user reads before a private operation moves their funds. The + * spend counts against the daily limit like a public transfer; an unknown + * token is never inside a limit. + */ +export function parseStarknetPrivacy(op: StarknetPrivacyOperation): ParsedTx { + const tx = emptyParsed(); + const token = knownStarknetToken(op.token); + const to = hex(op.to); + if (token) { + tx.spends.push({ asset: token.symbol, amount: op.amount, decimals: token.decimals }); + tx.lines.push({ kind: "send", amount: formatUnits(op.amount, token.decimals), asset: token.symbol, to }); + } else { + tx.flagged = true; + tx.lines.push({ kind: "send", amount: `${op.amount} (base units)`, asset: `unknown token ${hex(op.token)}`, to }); + } + tx.lines.push({ kind: "action", text: PRIVATE_NOTES[op.kind] }); + return tx; +} + +const PRIVATE_NOTES = { + deposit: "Private: moves the funds into your private balance in the privacy pool", + transfer: "Private: the amount and the receiver are hidden on-chain", + withdraw: "Private: takes the funds out of your private balance to a public address", +} as const; + /** Sponsored transactions are signed as a SNIP-9 OutsideExecution, which carries the calls. */ export function parseStarknetTypedData(typedData: TypedData, account: string): ParsedTx { const message = typedData.message as Record; diff --git a/src/vault/privacy.test.ts b/src/vault/privacy.test.ts new file mode 100644 index 0000000..279207d --- /dev/null +++ b/src/vault/privacy.test.ts @@ -0,0 +1,242 @@ +import { randomBytes } from "@noble/hashes/utils"; +import { deriveViewingKey } from "../chains/starknet/viewingKey"; +import { CHAIN_ID, mockPrivacy, PRIVATE_KEY } from "../chains/starknet/privacy/fixtures"; +import type { StarknetSpend } from "../chains/starknet/nativeConnect"; +import { createVaultHandler, serveVault } from "./host"; +import { memoryLedger } from "./ledger"; +import type { OverLimit } from "./limits"; +import type { Line } from "./policy"; +import { VaultClient } from "./VaultClient"; + +const params = { network: "sepolia", appSalt: "salt", userId: "u1", recovery: "none" as const }; + +function setup(options: { confirm?: () => Promise; overLimit?: OverLimit; configured?: boolean } = {}) { + const m = mockPrivacy(); + const asked: Line[][] = []; + const spend: StarknetSpend = { + address: () => m.alice, + publicKey: () => "0x1", + privateKey: () => PRIVATE_KEY, + }; + const channel = new MessageChannel(); + const wire: unknown[] = []; + const reply = channel.port2.postMessage.bind(channel.port2); + channel.port2.postMessage = ((message: unknown, ...rest: unknown[]) => { + wire.push(message); + return (reply as (...args: unknown[]) => void)(message, ...rest); + }) as typeof channel.port2.postMessage; + const handle = createVaultHandler({ + appId: "app", + backendUrl: "https://cavos.test", + policy: { + overLimit: options.overLimit ?? "ask", + limits: [{ chain: "starknet", asset: "STRK", perTx: "1", perDay: "3" }], + }, + ledger: memoryLedger(), + confirm: async (lines) => { + asked.push(lines); + return (options.confirm ?? (async () => true))(); + }, + passkey: () => ({ enroll: async () => ({ credentialId: new Uint8Array(16), secret: randomBytes(32) }), getSecret: async () => randomBytes(32) }), + resolveStarknet: async () => ({ address: m.alice, spend, isNewAccount: false }), + privacy: (network) => (options.configured === false ? undefined : network === "sepolia" ? m.config : undefined), + privacyDeps: m.init.deps, + }); + serveVault(channel.port2, handle); + const client = VaultClient.fromPort(channel.port1); + const raw = (method: string, requestParams: unknown) => + new Promise<{ ok: boolean; result?: unknown; error?: string }>((resolve) => { + const id = 9000 + Math.floor(Math.random() * 1000); + const listener = ({ data }: MessageEvent) => { + if (data.id !== id) return; + channel.port1.removeEventListener("message", listener); + resolve(data); + }; + channel.port1.addEventListener("message", listener); + channel.port1.postMessage({ id, method, params: requestParams }); + }); + const close = () => { + channel.port1.close(); + channel.port2.close(); + }; + return { m, client, asked, wire, raw, close }; +} + +const ONE_STRK = 10n ** 18n; + +describe("private transfers through the vault", () => { + it("runs register, deposit, transfer, balance and withdraw end to end", async () => { + const { m, client, close } = setup(); + const connected = await client.connectStarknet({ ...params }, "api-key"); + const privacy = connected.privacy!; + expect(await privacy.status()).toEqual({ registered: false }); + expect((await privacy.register()).transactionHash).toMatch(/^0x/); + expect(await privacy.status()).toEqual({ registered: true }); + + await privacy.deposit(m.strk, 5n); + expect(await privacy.balance(m.strk)).toBe(5n); + await privacy.withdraw(m.strk, m.alice, 2n); + expect(await privacy.balance(m.strk)).toBe(3n); + expect(m.built.every((entry) => entry.headers["x-paymaster-api-key"] === "api-key")).toBe(true); + close(); + }); + + it("transfers privately to a registered recipient", async () => { + const { m, client, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + await privacy!.deposit(m.strk, 10n); + const { createPrivacySession } = await import("../chains/starknet/privacy/session"); + await createPrivacySession(m.bobInit).register(); + await privacy!.transfer(m.strk, m.bob, 4n); + expect(await privacy!.balance(m.strk)).toBe(6n); + expect(await createPrivacySession(m.bobInit).balance(m.strk)).toBe(4n); + close(); + }); + + it("sends only hashes, balances and status across the boundary, never a key or a note", async () => { + const { m, client, wire, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + await privacy!.deposit(m.strk, 5n); + await privacy!.balance(m.strk); + await privacy!.status(m.strk); + + const viewingKey = deriveViewingKey(PRIVATE_KEY, { chainId: CHAIN_ID, accountAddress: m.alice, poolAddress: m.pool }); + const secrets = [viewingKey, BigInt(viewingKey).toString(), BigInt(viewingKey).toString(16), PRIVATE_KEY, BigInt(PRIVATE_KEY).toString(16)]; + const serialized = JSON.stringify(wire, (_key, value) => (typeof value === "bigint" ? value.toString() : value)); + for (const secret of secrets) expect(serialized.toLowerCase()).not.toContain(secret.toLowerCase()); + + const results = (wire as { ok?: boolean; result?: unknown }[]).filter((message) => message.ok && message.result); + for (const { result } of results) { + const shape = typeof result === "object" && result !== null ? Object.keys(result).sort() : typeof result; + expect([["registered"], ["registered", "requirement"], ["transactionHash"], "string", ["address", "handle", "isNewAccount", "passkey", "publicKey"]].some((allowed) => JSON.stringify(allowed) === JSON.stringify(shape))).toBe(true); + } + const balances = results.filter((message) => typeof message.result === "string" && /^\d+$/.test(message.result as string)); + expect(balances.map((message) => message.result)).toEqual(["5"]); + close(); + }); + + it("ignores a pool, prover or discovery the page tries to pass", async () => { + const { m, client, raw, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + const handle = `starknet:u1:salt`; + const answer = await raw("privacyDeposit", { + handle, + token: m.strk, + amount: "5", + paymasterApiKey: "api-key", + pool: "0xdead", + poolAddress: "0xdead", + prover: "https://evil.example", + discovery: "https://evil.example", + ohttp: false, + relay: { url: "https://evil.example" }, + config: { pool: "0xdead" }, + }); + expect(answer.ok).toBe(true); + const sent = m.built.map((entry) => entry.params.transaction.apply_action.pool_address); + expect(sent.every((pool: string) => pool === m.pool)).toBe(true); + close(); + }); + + it("asks before a deposit over the limit and not under it", async () => { + const { m, client, asked, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + expect(asked).toHaveLength(0); + + await privacy!.deposit(m.strk, 10n); + expect(asked).toHaveLength(0); + + await privacy!.deposit(m.strk, 2n * ONE_STRK); + expect(asked).toHaveLength(1); + expect(asked[0]![0]).toMatchObject({ kind: "send", amount: "2", asset: "STRK", to: `0x${BigInt(m.pool).toString(16).padStart(64, "0")}` }); + expect(asked[0]![1]).toMatchObject({ kind: "action", text: expect.stringContaining("Private") }); + close(); + }); + + it("asks before a private transfer or withdrawal over the limit, naming the receiver", async () => { + const { m, client, asked, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + await privacy!.deposit(m.strk, 3n * ONE_STRK); + asked.length = 0; + const { createPrivacySession } = await import("../chains/starknet/privacy/session"); + await createPrivacySession(m.bobInit).register(); + + await privacy!.transfer(m.strk, m.bob, 2n * ONE_STRK); + expect(asked[0]![0]).toMatchObject({ kind: "send", asset: "STRK", amount: "2", to: `0x${BigInt(m.bob).toString(16).padStart(64, "0")}` }); + await privacy!.withdraw(m.strk, m.alice, 2n); + expect(asked).toHaveLength(1); + close(); + }); + + it("always asks for a token it does not know", async () => { + const { m, client, asked, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + await privacy!.deposit(m.ace, 1n); + expect(asked).toHaveLength(1); + expect(asked[0]![0]).toMatchObject({ kind: "send", asset: expect.stringContaining("unknown token") }); + close(); + }); + + it("does not submit when the user rejects", async () => { + const { m, client, close } = setup({ confirm: async () => false }); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + const before = m.executed.length; + await expect(privacy!.deposit(m.strk, 2n * ONE_STRK)).rejects.toThrow(/rejected/); + await expect(privacy!.transfer(m.strk, m.bob, 2n * ONE_STRK)).rejects.toThrow(/rejected/); + await expect(privacy!.withdraw(m.strk, m.alice, 2n * ONE_STRK)).rejects.toThrow(/rejected/); + expect(m.executed).toHaveLength(before); + close(); + }); + + it("blocks without asking when the policy says so", async () => { + const { m, client, asked, close } = setup({ overLimit: "block" }); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await expect(privacy!.deposit(m.strk, 2n * ONE_STRK)).rejects.toThrow(/policy/); + expect(asked).toHaveLength(0); + close(); + }); + + it("asks nothing to register", async () => { + const { client, asked, close } = setup(); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await privacy!.register(); + expect(asked).toHaveLength(0); + close(); + }); + + it("says so when the app has no private transfer settings", async () => { + const { m, client, close } = setup({ configured: false }); + const { privacy } = await client.connectStarknet({ ...params }, "api-key"); + await expect(privacy!.status()).rejects.toThrow(/private transfer settings for sepolia/); + await expect(privacy!.deposit(m.strk, 1n)).rejects.toThrow(/private transfer settings/); + close(); + }); + + it("refuses malformed amounts and addresses", async () => { + const { m, client, raw, close } = setup(); + await client.connectStarknet({ ...params }, "api-key"); + const handle = "starknet:u1:salt"; + const base = { handle, token: m.strk, paymasterApiKey: "k" }; + expect((await raw("privacyDeposit", { ...base, amount: "-1" })).ok).toBe(false); + expect((await raw("privacyDeposit", { ...base, amount: "0" })).ok).toBe(false); + expect((await raw("privacyDeposit", { ...base, amount: "1.5" })).ok).toBe(false); + expect((await raw("privacyTransfer", { ...base, amount: "1", recipient: "bob" })).ok).toBe(false); + expect((await raw("privacyWithdraw", { ...base, amount: "1" })).ok).toBe(false); + expect(m.built).toHaveLength(0); + close(); + }); + + it("needs a connected account", async () => { + const { m, raw, close } = setup(); + const answer = await raw("privacyBalance", { handle: "starknet:nobody:salt", token: m.strk }); + expect(answer).toMatchObject({ ok: false, error: expect.stringContaining("connect first") }); + close(); + }); +}); diff --git a/src/vault/protocol.ts b/src/vault/protocol.ts index e2b7b72..2342fe4 100644 --- a/src/vault/protocol.ts +++ b/src/vault/protocol.ts @@ -1,4 +1,5 @@ import type { Call, InvocationsSignerDetails, TypedData } from "starknet"; +import type { PrivacyStatus, PrivacySubmission } from "../chains/starknet/privacy/session"; import type { SocialRecoveryCredential } from "../recovery/SocialRecoveryCredential"; export const VAULT_READY = "cavos-vault:ready"; @@ -42,6 +43,26 @@ export interface VaultMethods { signMessage(params: { handle: string; message: Uint8Array }): Uint8Array; signStarknetTypedData(params: { handle: string; typedData: TypedData; accountAddress: string }): string[]; signStarknetInvoke(params: { handle: string; calls: Call[]; details: InvocationsSignerDetails }): string[]; + /** The pool, prover, discovery and relay come from the app's Cavos settings. The page names none of them. */ + privacyStatus(params: { handle: string; token?: string }): PrivacyStatus; + /** Decimal string of base units. Only the sum leaves the vault, never a note. */ + privacyBalance(params: { handle: string; token: string }): string; + privacyRegister(params: { handle: string; paymasterApiKey: string }): PrivacySubmission; + privacyDeposit(params: { handle: string; token: string; amount: string; paymasterApiKey: string }): PrivacySubmission; + privacyTransfer(params: { + handle: string; + token: string; + recipient: string; + amount: string; + paymasterApiKey: string; + }): PrivacySubmission; + privacyWithdraw(params: { + handle: string; + token: string; + recipient: string; + amount: string; + paymasterApiKey: string; + }): PrivacySubmission; forget(params: { userId: string; appSalt: string }): void; /** Create a passkey that can restore the connected account on another device. */ enrollPasskey(params: { diff --git a/tsup.config.ts b/tsup.config.ts index 656a882..d92fe7b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -27,6 +27,7 @@ export default defineConfig([{ "expo-web-browser", "expo-linking", "react-native-get-random-values", + "@starkware-libs/starknet-privacy-sdk", ], }, { From cf060094298d311cc8deae6f96bc2743c6253b28 Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:25:25 -0600 Subject: [PATCH 2/4] Authenticate GitHub Packages in CI for the privacy SDK --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c87e6..3cfd45d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: name: Build and test runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + packages: read # the privacy SDK is on GitHub Packages, which needs auth even for public packages + env: + GH_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 @@ -25,6 +30,8 @@ jobs: node-version: "22" cache: npm + - run: npm config set "//npm.pkg.github.com/:_authToken" '${GH_PACKAGES_TOKEN}' + - run: npm ci - run: npm run build @@ -66,6 +73,9 @@ jobs: permissions: contents: write # push the release tag id-token: write # npm provenance via OIDC + packages: read # install the privacy SDK from GitHub Packages + env: + GH_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 @@ -75,6 +85,8 @@ jobs: cache: npm registry-url: "https://registry.npmjs.org" + - run: npm config set "//npm.pkg.github.com/:_authToken" '${GH_PACKAGES_TOKEN}' + - run: npm ci # A merge without a version bump is normal (docs, refactors). Skip quietly From 008fa96a73d0d59fae51fd4120623a987a19b8a0 Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:59 -0600 Subject: [PATCH 3/4] Keep the privacy SDK import out of the npm entries Bundlers resolve a literal dynamic import even when it is never reached, so every app on @cavos/kit failed to build without the optional SDK installed. The vault script now injects the loader, and page-held keys pass privacySdk with their privacy settings. --- src/chains/starknet/CavosStarknet.ts | 20 +++++++++++++++++--- src/chains/starknet/privacy/fixtures.ts | 1 + src/chains/starknet/privacy/session.ts | 6 ++++-- src/chains/starknet/privacyConnect.test.ts | 10 +++++++++- src/vault/browser/host.ts | 3 ++- src/vault/host.ts | 6 ++++++ 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/chains/starknet/CavosStarknet.ts b/src/chains/starknet/CavosStarknet.ts index 1e5726a..2ca6681 100644 --- a/src/chains/starknet/CavosStarknet.ts +++ b/src/chains/starknet/CavosStarknet.ts @@ -32,6 +32,7 @@ import { } from "./constants"; import { resolveNativeStarknet, type StarknetSpend } from "./nativeConnect"; import { parseStarknetPrivacyConfig, type StarknetPrivacyConfig } from "./privacy/config"; +import type { PrivacySdk } from "./privacy/sdk"; import { createPrivacySession, unavailablePrivacy, type PrivacyOperations } from "./privacy/session"; export interface ConnectStarknetOptions { @@ -62,6 +63,8 @@ export interface ConnectStarknetOptions { * passing this together with `vault` is refused. */ privacy?: StarknetPrivacyConfig; + /** Required with `privacy`: `() => import("@starkware-libs/starknet-privacy-sdk")`, so the app's own bundler resolves it. */ + privacySdk?: () => Promise; } const MESSAGE_TYPES = { @@ -197,7 +200,7 @@ export class CavosStarknet { if (opts.vault && opts.privacy) { throw new Error("kit/starknet: with a vault, private transfer settings come from the app in the Cavos dashboard, not from `privacy`"); } - const privacyConfig = opts.privacy ? parseStarknetPrivacyConfig(opts.privacy) : undefined; + const pagePrivacy = opts.privacy ? pagePrivacySettings(opts.privacy, opts.privacySdk) : undefined; let signer: SignerInterface | null; let address: string; @@ -235,9 +238,9 @@ export class CavosStarknet { passkey = Boolean(opts.passkey); privacy = !native.spend ? unavailablePrivacy(NO_KEY) - : privacyConfig + : pagePrivacy ? createPrivacySession({ - config: privacyConfig, + ...pagePrivacy, chainId: STARKNET_NETWORKS[opts.network].chainId, rpcUrl: opts.rpcUrl ?? STARKNET_NETWORKS[opts.network].rpcUrl, account: { address, spend: native.spend }, @@ -428,6 +431,17 @@ export class CavosStarknet { const defaultRegistry = new InMemoryWalletRegistry(); +function pagePrivacySettings( + privacy: StarknetPrivacyConfig, + privacySdk: (() => Promise) | undefined, +): { config: StarknetPrivacyConfig; loadSdk: () => Promise } { + const config = parseStarknetPrivacyConfig(privacy); + if (!privacySdk) { + throw new Error('kit/starknet: `privacy` needs `privacySdk: () => import("@starkware-libs/starknet-privacy-sdk")`'); + } + return { config, loadSdk: privacySdk }; +} + function selectorOf(entrypoint: string): bigint { return BigInt(/^0x[0-9a-f]+$/i.test(entrypoint) ? entrypoint : hash.getSelectorFromName(entrypoint)); } diff --git a/src/chains/starknet/privacy/fixtures.ts b/src/chains/starknet/privacy/fixtures.ts index dee0673..48f13d0 100644 --- a/src/chains/starknet/privacy/fixtures.ts +++ b/src/chains/starknet/privacy/fixtures.ts @@ -118,6 +118,7 @@ export function mockPrivacy(options: MockPrivacyOptions = {}) { chainId: CHAIN_ID, rpcUrl: "https://rpc.test", account: { address: alice, spend: { privateKey: () => PRIVATE_KEY } }, + loadSdk: async () => sdk, paymasterApiKey: () => "key-1", deps: { chain, diff --git a/src/chains/starknet/privacy/session.ts b/src/chains/starknet/privacy/session.ts index dc5a0b7..210c922 100644 --- a/src/chains/starknet/privacy/session.ts +++ b/src/chains/starknet/privacy/session.ts @@ -16,7 +16,7 @@ import { } from "./chain"; import type { PrivacyFeeMode, StarknetPrivacyConfig } from "./config"; import { PrivacyRelay, type FeeAction, type RelayCall } from "./relay"; -import { loadPrivacySdk, type PrivacySdk } from "./sdk"; +import type { PrivacySdk } from "./sdk"; export type PrivacyRequirement = "register" | "setup-channel" | "setup-token" | "ready"; @@ -65,6 +65,8 @@ export interface PrivacySessionInit { rpcUrl: string; account: { address: string; spend: { privateKey(): string } }; paymasterApiKey: () => string; + /** Loads the privacy SDK. Injected so that only builds that bundle it name the package. */ + loadSdk: () => Promise; deps?: PrivacySessionDeps; } @@ -291,7 +293,7 @@ export class PrivacySession implements PrivacyOperations { } private sdk(): Promise { - return this.init.deps?.sdk ? Promise.resolve(this.init.deps.sdk) : loadPrivacySdk(); + return this.init.deps?.sdk ? Promise.resolve(this.init.deps.sdk) : this.init.loadSdk(); } private client(): Promise { diff --git a/src/chains/starknet/privacyConnect.test.ts b/src/chains/starknet/privacyConnect.test.ts index 3362b12..a75bf2e 100644 --- a/src/chains/starknet/privacyConnect.test.ts +++ b/src/chains/starknet/privacyConnect.test.ts @@ -11,6 +11,7 @@ import { STARKNET_NETWORKS } from "./constants"; import * as session from "./privacy/session"; import type { PrivacyOperations } from "./privacy/session"; import type { StarknetPrivacyConfig } from "./privacy/config"; +import type { PrivacySdk } from "./privacy/sdk"; class MemoryStore implements WrapStore { private rows = new Map(); @@ -64,9 +65,11 @@ describe("CavosStarknet.privacy", () => { }; const create = jest.spyOn(session, "createPrivacySession").mockReturnValue(operations as session.PrivacySession); - const wallet = await connect({ privacy }); + const privacySdk = jest.fn(async () => ({}) as PrivacySdk); + const wallet = await connect({ privacy, privacySdk }); const init = create.mock.calls[0]![0]; + expect(init.loadSdk).toBe(privacySdk); expect(init.config.pool).toBe("0x123"); expect(init.chainId).toBe(STARKNET_NETWORKS.sepolia.chainId); expect(init.account.address).toBe(wallet.address); @@ -82,6 +85,11 @@ describe("CavosStarknet.privacy", () => { await expect(wallet.privacy.transfer("0x1", "0x2", 1n)).rejects.toThrow(/`privacy` in the connect options/); }); + it("asks the page for the SDK loader along with the settings", async () => { + jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); + await expect(connect({ privacy })).rejects.toThrow(/privacySdk/); + }); + it("validates the settings when connecting", async () => { jest.spyOn(RpcProvider.prototype, "getClassHashAt").mockRejectedValue(new Error("Contract not found")); await expect(connect({ privacy: { ...privacy, pool: "nope" } })).rejects.toThrow(/privacy\.pool/); diff --git a/src/vault/browser/host.ts b/src/vault/browser/host.ts index f69b126..1c488aa 100644 --- a/src/vault/browser/host.ts +++ b/src/vault/browser/host.ts @@ -1,4 +1,5 @@ +import { loadPrivacySdk } from "../../chains/starknet/privacy/sdk"; import { startVaultHost } from "../host"; const script = document.currentScript as HTMLScriptElement | null; -startVaultHost({ backendUrl: script?.dataset.backend ?? location.origin }); +startVaultHost({ backendUrl: script?.dataset.backend ?? location.origin, privacySdk: loadPrivacySdk }); diff --git a/src/vault/host.ts b/src/vault/host.ts index c2862e2..a728112 100644 --- a/src/vault/host.ts +++ b/src/vault/host.ts @@ -2,6 +2,7 @@ import type { ControlKey } from "../chains/stellar/WebCryptoControlKey"; import { STARKNET_NETWORKS, type StarknetNetwork } from "../chains/starknet/constants"; import { resolveNativeStarknet, type StarknetSpend } from "../chains/starknet/nativeConnect"; import { parseStarknetPrivacyConfig, type StarknetPrivacyConfig } from "../chains/starknet/privacy/config"; +import type { PrivacySdk } from "../chains/starknet/privacy/sdk"; import { PrivacySession, type PrivacySessionDeps } from "../chains/starknet/privacy/session"; import { resolveNativeSolana } from "../chains/solana/nativeConnect"; import { resolveNativeStellar } from "../chains/stellar/nativeConnect"; @@ -62,6 +63,8 @@ export interface VaultHandlerDeps { solanaNetwork?: (recentBlockhash: string) => Promise; /** Private transfer settings of the app for a network ("sepolia", "mainnet"), from the Cavos dashboard. Throws on an invalid one. */ privacy?: (network: string) => StarknetPrivacyConfig | undefined; + /** Loads the privacy SDK. Only a vault build that bundles it passes one. */ + privacySdk?: () => Promise; privacyDeps?: PrivacySessionDeps; } @@ -227,6 +230,7 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque if (!acct.paymasterApiKey) throw new Error("kit/vault: private transfers need the paymaster API key"); return acct.paymasterApiKey; }, + loadSdk: deps.privacySdk ?? (() => Promise.reject(new Error("kit/vault: this vault build cannot run private transfers"))), ...(deps.privacyDeps ? { deps: deps.privacyDeps } : {}), }); return { session: acct.privacy, config, chainId: network.chainId }; @@ -393,6 +397,7 @@ export interface VaultHostOptions { confirmUrl?: string; /** Defaults to `/api/vault/policy` on this origin. */ policyUrl?: string; + privacySdk?: () => Promise; } /** Runs inside the vault iframe. One embedding app per iframe. */ @@ -427,6 +432,7 @@ export function startVaultHost(opts: VaultHostOptions): void { attestation: opts.attestation, policy: app.policy, privacy: app.privacy, + ...(opts.privacySdk ? { privacySdk: opts.privacySdk } : {}), ledger: idbLedger(), confirm: (lines, context) => modal.review(lines, context.network), passkey: (user) => modal.passkey(user), From 1f53cdd814dbbcf91f4ba39f6851a827e83addb2 Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:59:27 -0600 Subject: [PATCH 4/4] Release as 0.4.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f06bde8..603a3d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cavos/kit", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cavos/kit", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", diff --git a/package.json b/package.json index 9753450..f9fd4ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cavos/kit", - "version": "0.4.2", + "version": "0.4.3", "description": "Embedded Stellar wallet SDK, embedded Solana wallet, and embedded Starknet wallet. Device-native self-custodial accounts for React Native and web.", "repository": { "type": "git",