diff --git a/CLAUDE.md b/CLAUDE.md index 4c4b69e..6762b2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,18 @@ With a pre-built metadata URI: rare mint --contract
--token-uri [--to
] [--royalty-receiver
] [--chain ] ``` +### Backup + +Resolve an existing token, quote its billable bytes, and optionally preserve it through a hosted x402-backed service: + +```bash +rare backup token --contract --token-id [--chain ] --quote-only +rare backup token --contract --token-id [--chain ] --payment-chain +rare backup token --contract --token-id [--chain-id ] [--payment-chain-id ] +``` + +Backup requests default to `https://api.superrare.com`, and IPFS fetches/receipt links default to `https://superrare.myfilebase.com`. + ### Auction Lifecycle ```bash diff --git a/README.md b/README.md index 44f8849..beb8dff 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,42 @@ rare mint \ --royalty-receiver 0x... ``` +### Back Up an Existing NFT + +Resolve an existing token's `tokenURI`, fetch its metadata and directly referenced media locally, request a hosted preservation quote, and optionally pay/pin via x402 using `RARE`. + +```bash +# Quote only +rare backup token \ + --contract 0x... \ + --token-id 1 \ + --chain sepolia \ + --quote-only + +# Full preserve flow +rare backup token \ + --contract 0x... \ + --token-id 1 \ + --chain-id 1 \ + --payment-chain base +``` + +Useful options: + +```bash +rare backup token \ + --contract 0x... \ + --token-id 1 \ + --service-url https://your-preservation-service.com \ + --max-bytes 1073741824 +``` + +The CLI defaults to `https://api.superrare.com` for preservation requests and uses `https://superrare.myfilebase.com` as its IPFS gateway. Use `--service-url` only when you need to point the backup flow at a different API host. + +For paid preserves, the selected `--payment-chain` or `--payment-chain-id` must have both a private key and RPC URL configured. The backup flow does not auto-generate wallets. Before any payment-capable request, the CLI now prints the quote and asks for confirmation. Use `--yes` to skip the prompt in automation. + +Preservation currently only supports CID-backed IPFS metadata and media references. Use `ipfs://...` URIs or IPFS gateway URLs like `https://ipfs.io/ipfs/...`. + ### Auctions ```bash @@ -317,6 +353,21 @@ await rare.import.erc721({ }); ``` +### Quote or preserve an existing NFT + +`backup.quoteTokenPreservation` resolves metadata/media locally, computes exact byte counts and hashes, then requests a hosted quote. `backup.preserveToken` continues through upload session creation, uploads the staged bytes, and finalizes the receipt. + +```ts +const quote = await rare.backup.quoteTokenPreservation({ + serviceUrl: 'https://your-preservation-service.com', + contract: '0xYourContractAddress', + tokenId: '1', + sourceChain: 'sepolia', +}); + +console.log(quote.billableBytes, quote.tokenAmount); +``` + ## Configuration Config is stored at `~/.rare/config.json`. Each chain has its own private key and RPC URL. @@ -334,7 +385,6 @@ rare configure --default-chain mainnet # View current config rare configure --show -``` ## Best Practices diff --git a/docs/preservation-seller.md b/docs/preservation-seller.md new file mode 100644 index 0000000..85bb358 --- /dev/null +++ b/docs/preservation-seller.md @@ -0,0 +1,112 @@ +# Preservation Seller Service + +This CLI now expects a separate hosted preservation service for paid IPFS backup flows. The service is not part of `rare-cli`; this document captures the buyer-facing contract and the intended x402 seller responsibilities. + +## Responsibilities + +- Accept unpaid preservation quote requests. +- Challenge paid upload-session requests with `402 Payment Required`. +- Verify `x402` payments in `RARE` on one of: + - `mainnet` + - `sepolia` + - `base` + - `base-sepolia` +- Return upload targets after payment succeeds. +- Verify uploaded byte counts and SHA-256 hashes against the quoted asset descriptors. +- Pin verified bytes to IPFS. +- Assemble and pin a preservation manifest. +- Return a private receipt to the payer. + +## Expected Routes + +- `POST /v1/preservations/quotes` +- `POST /v1/preservations/quotes/:quoteId/upload-session` +- `POST /v1/preservations/quotes/:quoteId/finalize` +- `GET /v1/preservations/receipts/:receiptId` + +## Quote Request Shape + +```json +{ + "source": { + "chain": "sepolia", + "chainId": 11155111, + "contractAddress": "0x...", + "tokenId": "1", + "universalTokenId": "11155111-0x...-1", + "tokenUri": "ipfs://..." + }, + "assets": [ + { + "assetId": "asset_0000", + "role": "metadata", + "originalUri": "ipfs://...", + "filename": "metadata.json", + "mimeType": "application/json", + "size": 1234, + "sha256": "..." + } + ], + "preferredPaymentChain": "base" +} +``` + +## Quote Response Shape + +```json +{ + "quoteId": "quote_123", + "expiresAt": "2026-01-01T00:00:00.000Z", + "billableBytes": 1234, + "tokenAmount": "86000460000000", + "ratePerByteAtomic": "69690000000", + "source": {}, + "assets": [], + "acceptedPayments": [ + { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x691077c8e8de54ea84efd454630439f99bd8c92f", + "payTo": "0xReceivingWallet", + "amount": "86000460000000", + "maxTimeoutSeconds": 300, + "extra": null + } + ] +} +``` + +## Finalize Response Shape + +`POST /v1/preservations/quotes/:quoteId/finalize` should return a receipt payload. The CLI can derive fallback values for the manifest gateway link and quote expiration, but sellers should prefer returning them explicitly: + +```json +{ + "receiptId": "receipt_123", + "quoteId": "quote_123", + "expiresAt": "2026-01-01T00:05:00.000Z", + "manifestCid": "bafy...", + "manifestIpfsUrl": "ipfs://bafy...", + "manifestGatewayUrl": "https://your.gateway/ipfs/bafy...", + "billableBytes": 1234, + "payment": {}, + "assets": [], + "source": {}, + "createdAt": "2026-01-01T00:01:00.000Z" +} +``` + +## Pricing + +- Rate: `0.00006969 RARE / kb` +- `kb` is `1000` bytes +- Billing is exact-bytes, not rounded buckets +- Atomic rate: `69_690_000_000` +- Formula: `totalChargeAtomic = totalBillableBytes * 69_690_000_000` + +## x402 Notes + +- The CLI buyer uses the official `@x402/fetch` and `@x402/evm` packages. +- The hosted seller should use the official `x402` seller middleware/helpers. +- The service should advertise the `payment-identifier` extension so retries can be de-duplicated safely. +- The monetized boundary is the hosted service. The CLI is open source and can be forked; payment enforcement only applies to the hosted seller. diff --git a/package-lock.json b/package-lock.json index fcd35af..fec8f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "@rareprotocol/rare-cli", - "version": "0.4.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rareprotocol/rare-cli", - "version": "0.4.1", + "version": "1.0.0", "license": "MIT", "dependencies": { + "@rareprotocol/rare-cli": "^0.4.1", + "@x402/evm": "^2.10.0", + "@x402/fetch": "^2.10.0", "commander": "^12.0.0", "openapi-fetch": "^0.17.0", "viem": "^2.0.0" @@ -576,6 +579,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@rareprotocol/rare-cli": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@rareprotocol/rare-cli/-/rare-cli-0.4.1.tgz", + "integrity": "sha512-D3RX7oMK1EB4t1Pa8UEzSBTFf24NCNO4O6WSFQe2sy5OsYJlYbpQHi6FCLug5fCVGmrAZHxzYIzF3WdwTbPoqA==", + "license": "MIT", + "dependencies": { + "commander": "^12.0.0", + "viem": "^2.0.0" + }, + "bin": { + "rare": "dist/index.js" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -1015,6 +1034,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@x402/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.10.0.tgz", + "integrity": "sha512-n9Exnt1HN4LFaINaPYhk6Cy3ICBt0e46XN1Uo5i6efIZfIoqP6pY8ONSX/M9bU4F1fpvMj0JZ3xdcBZCiGInfw==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.10.0.tgz", + "integrity": "sha512-iEGIgW5K3qM3d2S1wuBSGJ1Kfdctd+0LAN8l+33dbYZgdkvCvTDwBPjbojVJ9mXI0Zk2bt4hUpcoOgsdmr79BA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.10.0", + "viem": "^2.39.3", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/fetch": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.10.0.tgz", + "integrity": "sha512-Rpe7JL0wzsdRmUfzULCjWI+yq5dkEtYFdE7qbtL81VMdOjq3E50fxJd3dcg/U+Lam+CAHQA5FZkcjP932Xyk0A==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.10.0", + "viem": "^2.39.3", + "zod": "^3.24.2" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -2030,6 +2080,15 @@ "engines": { "node": ">=12" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 8464433..974cdbb 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,15 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "generate:types": "openapi-typescript https://rare-api-8426-784573620320.us-east1.run.app/doc -o src/data-access/schema.d.ts", + "generate:types": "openapi-typescript https://api.superrare.com/doc -o src/data-access/schema.d.ts", + "test": "npm run build && node --test test/**/*.test.mjs", "prepare": "npm run build", "prepublishOnly": "npm run build" }, "dependencies": { + "@rareprotocol/rare-cli": "^0.4.1", + "@x402/evm": "^2.10.0", + "@x402/fetch": "^2.10.0", "commander": "^12.0.0", "openapi-fetch": "^0.17.0", "viem": "^2.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..510213e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1339 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@rareprotocol/rare-cli': + specifier: ^0.4.1 + version: 0.4.1(typescript@5.9.3)(zod@3.25.76) + '@x402/evm': + specifier: ^2.10.0 + version: 2.10.0(typescript@5.9.3) + '@x402/fetch': + specifier: ^2.10.0 + version: 2.10.0(typescript@5.9.3) + commander: + specifier: ^12.0.0 + version: 12.1.0 + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 + viem: + specifier: ^2.0.0 + version: 2.48.0(typescript@5.9.3)(zod@3.25.76) + devDependencies: + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) + tsup: + specifier: ^8.0.0 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@rareprotocol/rare-cli@0.4.1': + resolution: {integrity: sha512-D3RX7oMK1EB4t1Pa8UEzSBTFf24NCNO4O6WSFQe2sy5OsYJlYbpQHi6FCLug5fCVGmrAZHxzYIzF3WdwTbPoqA==} + engines: {node: '>=22'} + hasBin: true + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.11': + resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@x402/core@2.10.0': + resolution: {integrity: sha512-n9Exnt1HN4LFaINaPYhk6Cy3ICBt0e46XN1Uo5i6efIZfIoqP6pY8ONSX/M9bU4F1fpvMj0JZ3xdcBZCiGInfw==} + + '@x402/evm@2.10.0': + resolution: {integrity: sha512-iEGIgW5K3qM3d2S1wuBSGJ1Kfdctd+0LAN8l+33dbYZgdkvCvTDwBPjbojVJ9mXI0Zk2bt4hUpcoOgsdmr79BA==} + + '@x402/fetch@2.10.0': + resolution: {integrity: sha512-Rpe7JL0wzsdRmUfzULCjWI+yq5dkEtYFdE7qbtL81VMdOjq3E50fxJd3dcg/U+Lam+CAHQA5FZkcjP932Xyk0A==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + ox@0.14.17: + resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + viem@2.48.0: + resolution: {integrity: sha512-0uLzTAUNKPpY9Cf3OBCPdwClXx9CEHAkoVYnxMPdHt7cRI1DobMso+pHZvU7itD+hFwE4htmp9QfP+5lb+kn0g==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@rareprotocol/rare-cli@0.4.1(typescript@5.9.3)(zod@3.25.76)': + dependencies: + commander: 12.1.0 + viem: 2.48.0(typescript@5.9.3)(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.11(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@types/estree@1.0.8': {} + + '@x402/core@2.10.0': + dependencies: + zod: 3.25.76 + + '@x402/evm@2.10.0(typescript@5.9.3)': + dependencies: + '@x402/core': 2.10.0 + viem: 2.48.0(typescript@5.9.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@x402/fetch@2.10.0(typescript@5.9.3)': + dependencies: + '@x402/core': 2.10.0 + viem: 2.48.0(typescript@5.9.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + change-case@5.4.4: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + colorette@1.4.0: {} + + commander@12.1.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + eventemitter3@5.0.1: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.1 + + fsevents@2.3.3: + optional: true + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + index-to-position@1.2.0: {} + + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + + joycon@3.1.1: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.11(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + ox@0.14.17(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pluralize@8.0.0: {} + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@5.0.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@10.2.2: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@10.2.2) + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.60.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uri-js-replace@1.0.1: {} + + viem@2.48.0(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3) + ox: 0.14.17(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + ws@8.18.3: {} + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + zod@3.25.76: {} diff --git a/src/client.ts b/src/client.ts index bf90f0c..92e0980 100644 --- a/src/client.ts +++ b/src/client.ts @@ -57,3 +57,29 @@ export function getWalletClient(chain: SupportedChain) { account, }; } + +export function getWalletClientStrict(chain: SupportedChain) { + const chainConfig = getChainConfig(chain); + if (!chainConfig.privateKey) { + throw new Error( + `No private key configured for "${chain}". Run: rare configure --chain ${chain} --private-key 0x...` + ); + } + + if (!chainConfig.rpcUrl) { + throw new Error( + `No RPC URL configured for "${chain}". Run: rare configure --chain ${chain} --rpc-url ` + ); + } + + const account = privateKeyToAccount(chainConfig.privateKey as `0x${string}`); + return { + client: createWalletClient({ + chain: viemChains[chain], + transport: http(chainConfig.rpcUrl), + account, + }), + account, + rpcUrl: chainConfig.rpcUrl, + }; +} diff --git a/src/commands/backup.ts b/src/commands/backup.ts new file mode 100644 index 0000000..5e49c43 --- /dev/null +++ b/src/commands/backup.ts @@ -0,0 +1,732 @@ +import process from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { Command } from 'commander'; +import { formatEther } from 'viem'; +import { getWalletClientStrict, getPublicClient } from '../client.js'; +import { getActiveChain } from '../config.js'; +import { supportedChainFromChainId, type SupportedChain } from '../contracts/addresses.js'; +import { output, log, isJsonMode } from '../output.js'; +import { resolveTokenPreservation, type ResolvedTokenPreservation } from '../sdk/backup-resolver.js'; +import { + DEFAULT_PRESERVATION_GATEWAY_URL, + DEFAULT_PRESERVATION_MAX_BYTES, + DEFAULT_PRESERVATION_SERVICE_URL, + createPreservationUploadSession, + finalizeTokenPreservation, + paymentNetworkForChain, + quoteTokenPreservation as quoteTokenPreservationApi, + uploadPreservationAssets, + type PreservationAsset, + type PreservationFinalizeJobStatus, + type PreservationQuotePaymentStatus, + type PreservationUploadProgress, + type PreservationQuote, + type PreservationReceipt, +} from '../sdk/backup-service.js'; +import { createRareClient } from '../sdk/client.js'; +import { createX402PaymentFetch } from '../sdk/x402-client.js'; + +export function backupCommand(): Command { + const cmd = new Command('backup'); + cmd.description('Preserve existing NFTs through a hosted x402/IPFS backup service'); + + cmd + .command('token') + .description('Back up an existing NFT metadata URI and its directly referenced media') + .option('--contract
', 'token contract address') + .option('--token-id ', 'token ID to preserve') + .option('--chain ', 'source chain name for on-chain tokenURI resolution') + .option('--chain-id ', 'source chain ID for on-chain tokenURI resolution') + .option('--payment-chain ', 'payment chain name for RARE/x402 payment') + .option('--payment-chain-id ', 'payment chain ID for RARE/x402 payment') + .option('--quote-only', 'resolve bytes and request a quote without paying') + .option('-y, --yes', 'approve the quoted preservation cost without prompting') + .option('--service-url ', `override the preservation service URL (default: ${DEFAULT_PRESERVATION_SERVICE_URL})`) + .option('--gateway ', 'override the IPFS gateway used for asset fetches') + .option('--max-bytes ', 'maximum bytes to preserve before aborting') + .action(async (opts) => { + validateTargetOptions(opts); + + const sourceChain = resolveSourceChain(opts); + const paymentChain = resolvePaymentChain(opts, sourceChain); + const gatewayUrl = opts.gateway ?? DEFAULT_PRESERVATION_GATEWAY_URL; + const maxBytes = parseMaxBytes(opts.maxBytes); + const serviceUrl = opts.serviceUrl ?? DEFAULT_PRESERVATION_SERVICE_URL; + + const sourceClients = new Map>([ + [sourceChain, getPublicClient(sourceChain)], + ]); + const publicClientResolver = (chain: SupportedChain) => { + const existing = sourceClients.get(chain); + if (existing) return existing; + const created = getPublicClient(chain); + sourceClients.set(chain, created); + return created; + }; + + const backupParams = { + serviceUrl, + contract: opts.contract as `0x${string}` | undefined, + tokenId: opts.tokenId, + sourceChain, + paymentChain, + gatewayUrl, + maxBytes, + publicClientResolver, + }; + + if (opts.quoteOnly) { + const rare = createRareClient({ + publicClient: publicClientResolver(sourceChain), + }); + log(`Resolving NFT and requesting a preservation quote on ${sourceChain}...`); + const quote = await rare.backup.quoteTokenPreservation(backupParams); + output(quote, () => { + printQuote(quote, paymentChain); + }); + return; + } + + const paymentWallet = getWalletClientStrict(paymentChain); + log(`Resolving NFT and requesting a preservation quote on ${sourceChain}...`); + const resolved = await resolveTokenPreservation({ + publicClient: publicClientResolver(sourceChain), + chain: sourceChain, + contract: backupParams.contract, + tokenId: backupParams.tokenId, + gatewayUrl: backupParams.gatewayUrl, + maxBytes: backupParams.maxBytes, + }); + const quote = await quoteTokenPreservationApi({ + serviceUrl, + request: createQuoteRequest(resolved, paymentChain), + }); + + const confirmed = await confirmPreservationQuote({ + quote, + paymentChain, + assumeYes: Boolean(opts.yes), + }); + if (!confirmed) { + log('Preservation cancelled.'); + return; + } + + log(`Preparing preservation payment on ${paymentChain}...`); + const result = await preserveQuotedToken({ + serviceUrl, + paymentChain, + paymentWallet, + quote, + resolved, + }); + + output(result, () => { + printReceipt(result.receipt, { + quote: result.quote, + gatewayUrl, + }); + }); + }); + + return cmd; +} + +function validateTargetOptions(opts: { + contract?: string; + tokenId?: string; +}): void { + if (!opts.contract || !opts.tokenId) { + throw new Error('Pass both --contract and --token-id.'); + } +} + +function resolveSourceChain(opts: { chain?: string; chainId?: string }): SupportedChain { + return resolveChainSelection({ + chain: opts.chain, + chainId: opts.chainId, + chainFlag: '--chain', + chainIdFlag: '--chain-id', + defaultChain: () => getActiveChain(), + }); +} + +function resolvePaymentChain( + opts: { paymentChain?: string; paymentChainId?: string }, + sourceChain: SupportedChain, +): SupportedChain { + return resolveChainSelection({ + chain: opts.paymentChain, + chainId: opts.paymentChainId, + chainFlag: '--payment-chain', + chainIdFlag: '--payment-chain-id', + defaultChain: () => sourceChain, + }); +} + +function resolveChainSelection(opts: { + chain?: string; + chainId?: string; + chainFlag: string; + chainIdFlag: string; + defaultChain: () => SupportedChain; +}): SupportedChain { + const namedChain = opts.chain ? getActiveChain(opts.chain) : undefined; + const idChain = opts.chainId ? parseSupportedChainId(opts.chainId, opts.chainIdFlag) : undefined; + + if (namedChain && idChain && namedChain !== idChain) { + throw new Error(`${opts.chainFlag} and ${opts.chainIdFlag} refer to different chains.`); + } + + return namedChain ?? idChain ?? opts.defaultChain(); +} + +function parseSupportedChainId(rawValue: string, flagName: string): SupportedChain { + if (!/^\d+$/.test(rawValue)) { + throw new Error(`${flagName} must be an integer chain ID.`); + } + + const chainId = Number.parseInt(rawValue, 10); + const chain = supportedChainFromChainId(chainId); + if (!chain) { + throw new Error(`${flagName} must be one of: 1, 11155111, 8453, 84532.`); + } + + return chain; +} + +function parseMaxBytes(rawValue: string | undefined): number { + if (rawValue === undefined) { + return DEFAULT_PRESERVATION_MAX_BYTES; + } + + const value = Number.parseInt(rawValue, 10); + if (!Number.isInteger(value) || value <= 0) { + throw new Error('--max-bytes must be a positive integer.'); + } + + return value; +} + +function createQuoteRequest( + resolved: ResolvedTokenPreservation, + paymentChain: SupportedChain, +): { + source: PreservationQuote['source']; + assets: PreservationQuote['assets']; + preferredPaymentChain: SupportedChain; +} { + return { + source: resolved.source, + assets: resolved.assets.map(({ assetId, role, originalUri, filename, mimeType, size, sha256 }) => ({ + assetId, + role, + originalUri, + filename, + mimeType, + size, + sha256, + })), + preferredPaymentChain: paymentChain, + }; +} + +async function confirmPreservationQuote(opts: { + quote: PreservationQuote; + paymentChain: SupportedChain; + assumeYes: boolean; +}): Promise { + if (!isJsonMode()) { + printQuote(opts.quote, opts.paymentChain); + } + + if (opts.assumeYes) { + return true; + } + + if (isJsonMode()) { + throw new Error( + 'Preservation payments now require confirmation. Re-run without --json to confirm interactively, or pass --yes to accept the quoted cost.' + ); + } + + if (!hasInteractiveTerminal()) { + throw new Error( + 'Preservation payment requires confirmation, but no interactive terminal is available. Re-run with --yes to accept the quoted cost, or use --quote-only.' + ); + } + + const answer = await askQuestion( + `\nProceed with preservation payment of ${formatRareAmount(opts.quote.tokenAmount)} on ${displayChainName(opts.paymentChain)}? [y/N] ` + ); + + return isAffirmativeAnswer(answer); +} + +function hasInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +async function askQuestion(question: string): Promise { + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + return await readline.question(question); + } finally { + readline.close(); + } +} + +function isAffirmativeAnswer(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + +async function preserveQuotedToken(opts: { + serviceUrl: string; + paymentChain: SupportedChain; + paymentWallet: ReturnType; + quote: PreservationQuote; + resolved: ResolvedTokenPreservation; +}): Promise<{ + quote: PreservationQuote; + receipt: PreservationReceipt; +}> { + const selectedNetwork = paymentNetworkForChain(opts.paymentChain); + if (!opts.quote.acceptedPayments.some((option) => option.network === selectedNetwork)) { + throw new Error( + `Preservation service does not advertise a payment option for "${opts.paymentChain}" (${selectedNetwork}).` + ); + } + + const paymentFetch = createX402PaymentFetch({ + paymentChain: opts.paymentChain, + rpcUrl: opts.paymentWallet.rpcUrl, + account: opts.paymentWallet.account, + }); + + const uploadSession = await createPreservationUploadSession({ + serviceUrl: opts.serviceUrl, + quoteId: opts.quote.quoteId, + fetchImpl: paymentFetch, + statusFetchImpl: fetch, + onPaymentStatusUpdate: createPaymentStatusLogger(), + }); + + log('Uploading quoted assets directly to preservation storage...'); + const uploadLogger = createPreservationUploadLogger(); + try { + await uploadPreservationAssets( + opts.serviceUrl, + uploadSession, + opts.resolved.assets, + fetch, + uploadLogger.onProgress, + ); + } finally { + uploadLogger.cleanup(); + } + + log('Waiting for preservation finalization...'); + const receipt = await finalizeTokenPreservation({ + serviceUrl: opts.serviceUrl, + quoteId: opts.quote.quoteId, + uploadToken: uploadSession.uploadToken, + onStatusUpdate: createFinalizeStatusLogger(), + }); + + return { + quote: opts.quote, + receipt, + }; +} + +function printQuote(quote: PreservationQuote, paymentChain: SupportedChain): void { + console.log('\nPreservation quote:'); + console.log(` Quote ID: ${quote.quoteId}`); + console.log(` Source: ${quote.source.universalTokenId}`); + console.log(` Token URI: ${quote.source.tokenUri}`); + console.log(` Payment chain: ${paymentChain}`); + console.log(` Billable bytes: ${quote.billableBytes}`); + console.log(` Amount: ${formatRareAmount(quote.tokenAmount)}`); + console.log(` Expires: ${quote.expiresAt}`); + console.log(` Assets: ${quote.assets.length}`); +} + +function createPreservationUploadLogger(): { + onProgress: (progress: PreservationUploadProgress) => void; + cleanup: () => void; +} { + const progressLine = createSingleLineProgressRenderer(); + + const onProgress = (progress: PreservationUploadProgress) => { + const assetLabel = `${progress.assetIndex + 1}/${progress.assetCount} (${progress.assetId})`; + const isMultipartAsset = (progress.partCount ?? 0) > 1; + + if (progress.phase === 'asset-started') { + if (isMultipartAsset && progressLine.enabled) { + progressLine.render(formatUploadProgressLine(assetLabel, progress)); + return; + } + + log(`Uploading preservation asset ${assetLabel} (${progress.totalBytes} bytes)...`); + return; + } + + if (progress.phase === 'part-completed') { + if (isMultipartAsset && progressLine.enabled) { + progressLine.render(formatUploadProgressLine(assetLabel, progress)); + return; + } + + if ((progress.partCount ?? 0) > 1 && progress.partNumber !== null) { + log( + `Uploaded preservation asset ${assetLabel} part ${progress.partNumber}/${progress.partCount} (${progress.uploadedBytes}/${progress.totalBytes} bytes).`, + ); + } + return; + } + + if (isMultipartAsset) { + progressLine.clear(); + } + + log(`Verified preservation upload for asset ${assetLabel}.`); + }; + + return { + onProgress, + cleanup: () => progressLine.clear(), + }; +} + +function createPaymentStatusLogger(): (status: PreservationQuotePaymentStatus) => void { + let previousPaymentStatus: string | null = null; + + return (status) => { + if (status.paymentStatus === previousPaymentStatus) { + return; + } + + previousPaymentStatus = status.paymentStatus; + const message = formatPaymentStatusMessage(status); + if (message) { + log(message); + } + }; +} + +function formatPaymentStatusMessage( + status: PreservationQuotePaymentStatus, +): string | null { + switch (status.paymentStatus) { + case 'not_started': + return null; + case 'pending': + return 'Preservation payment facilitation in progress.'; + case 'settled': + return 'Preservation payment settled.'; + default: + return `Preservation payment ${status.paymentStatus.replace(/_/g, ' ')}.`; + } +} + +function createFinalizeStatusLogger(): (status: PreservationFinalizeJobStatus) => void { + let previousPhase: string | null = null; + + return (status) => { + const phase = status.progressPhase ?? status.status; + if (phase === previousPhase) { + return; + } + + previousPhase = phase; + log(formatFinalizeStatusMessage(status, phase)); + }; +} + +function formatFinalizeStatusMessage( + status: PreservationFinalizeJobStatus, + phase: string, +): string { + const suffix = status.jobId ? ` (${status.jobId}).` : '.'; + + switch (phase) { + case 'queued': + return `Preservation finalize job queued${suffix}`; + case 'processing': + return `Preservation finalize job processing${suffix}`; + case 'resolving_settlement': + return `Preservation finalize job resolving settlement${suffix}`; + case 'pinning_assets': + return `Preservation finalize job pinning assets${suffix}`; + case 'pinning_manifest': + return `Preservation finalize job pinning manifest${suffix}`; + case 'persisting_receipt': + return `Preservation finalize job persisting receipt${suffix}`; + case 'completed': + return `Preservation finalize job completed${suffix}`; + case 'failed': + return `Preservation finalize job failed${suffix}`; + default: + return `Preservation finalize job ${phase.replace(/_/g, ' ')}${suffix}`; + } +} + +function createSingleLineProgressRenderer(): { + enabled: boolean; + render: (line: string) => void; + clear: () => void; +} { + const enabled = !isJsonMode() && Boolean(process.stdout.isTTY); + let lastRenderedLength = 0; + + const clear = () => { + if (!enabled || lastRenderedLength === 0) { + return; + } + + process.stdout.write(`\r${' '.repeat(lastRenderedLength)}\r`); + lastRenderedLength = 0; + }; + + const render = (line: string) => { + if (!enabled) { + return; + } + + const truncatedLine = truncateForTerminal(line, process.stdout.columns ?? 80); + const paddedLine = truncatedLine.padEnd(lastRenderedLength, ' '); + process.stdout.write(`\r${paddedLine}`); + lastRenderedLength = truncatedLine.length; + }; + + return { + enabled, + render, + clear, + }; +} + +function formatUploadProgressLine( + assetLabel: string, + progress: PreservationUploadProgress, +): string { + const totalBytes = progress.totalBytes; + const uploadedBytes = Math.min(progress.uploadedBytes, totalBytes); + const ratio = totalBytes > 0 ? uploadedBytes / totalBytes : 0; + const percentage = `${(ratio * 100).toFixed(1)}%`; + const barWidth = resolveProgressBarWidth(process.stdout.columns ?? 80); + const bar = renderProgressBar(ratio, barWidth); + const byteSummary = `${formatByteCount(uploadedBytes)}/${formatByteCount(totalBytes)}`; + const partSummary = `${progress.partNumber ?? 0}/${progress.partCount ?? 0} parts`; + + return `Uploading preservation asset ${assetLabel} ${bar} ${percentage} ${byteSummary} (${partSummary})`; +} + +function resolveProgressBarWidth(columns: number): number { + if (columns >= 140) return 28; + if (columns >= 110) return 20; + if (columns >= 90) return 16; + return 12; +} + +function renderProgressBar(ratio: number, width: number): string { + const clampedRatio = Math.max(0, Math.min(1, ratio)); + const filledWidth = Math.round(clampedRatio * width); + return `[${'#'.repeat(filledWidth)}${'-'.repeat(width - filledWidth)}]`; +} + +function formatByteCount(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) { + return '0 B'; + } + + if (bytes < 1024) { + return `${bytes} B`; + } + + const units = ['KiB', 'MiB', 'GiB', 'TiB']; + let value = bytes / 1024; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + const digits = + value >= 100 ? 0 + : value >= 10 ? 1 + : 2; + + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +function truncateForTerminal(line: string, columns: number): string { + if (columns <= 0 || line.length <= columns) { + return line; + } + + if (columns <= 3) { + return '.'.repeat(columns); + } + + return `${line.slice(0, columns - 3)}...`; +} + +function printReceipt( + receipt: PreservationReceipt, + opts: { + quote?: PreservationQuote; + gatewayUrl?: string; + } = {}, +): void { + console.log('\nPreservation complete:'); + console.log(` Receipt ID: ${receipt.receiptId}`); + console.log(` Quote ID: ${receipt.quoteId}`); + const expiresAt = resolvePreservationExpiration(receipt, opts.quote); + if (expiresAt) { + console.log(` Expires: ${expiresAt}`); + } + console.log(` Source: ${receipt.source.universalTokenId}`); + console.log(` Billable bytes: ${receipt.billableBytes}`); + console.log(` Amount paid: ${formatRareAmount(receipt.payment.tokenAmount)}`); + console.log(` Payment rail: ${formatPaymentRail(receipt.payment.network)}`); + if (receipt.payment.payerAddress) { + console.log(` Payer: ${receipt.payment.payerAddress}`); + } + const settlementTx = resolveSettlementTransaction(receipt); + if (settlementTx) { + console.log(` Settlement tx: ${settlementTx}`); + } + const manifestGatewayUrl = resolveManifestGatewayUrl(receipt, opts.gatewayUrl); + if (manifestGatewayUrl) { + console.log(` Your Receipt: ${manifestGatewayUrl}`); + } + console.log(` Assets pinned: ${receipt.assets.length}`); + const pinnedAssetLinks = receipt.assets + .map((asset) => ({ + label: formatPinnedAssetLabel(asset), + url: resolvePreservedAssetGatewayUrl(asset, opts.gatewayUrl), + })) + .filter((asset): asset is { label: string; url: string } => asset.url !== null); + if (pinnedAssetLinks.length > 0) { + console.log(' Asset links:'); + for (const asset of pinnedAssetLinks) { + console.log(` ${asset.label}: ${asset.url}`); + } + } +} + +function formatRareAmount(tokenAmount: string): string { + return `${formatEther(BigInt(tokenAmount))} RARE`; +} + +function formatPaymentRail(network: string): string { + const chainName = chainNameFromPaymentNetwork(network); + return chainName ? `RARE on ${chainName}` : `RARE on ${network}`; +} + +function chainNameFromPaymentNetwork(network: string): string | null { + const match = /^eip155:(\d+)$/.exec(network); + if (!match) return null; + + const chainId = Number.parseInt(match[1], 10); + if (!Number.isSafeInteger(chainId)) return null; + + const chain = supportedChainFromChainId(chainId); + return chain ? displayChainName(chain) : network; +} + +function displayChainName(chain: SupportedChain): string { + switch (chain) { + case 'mainnet': + return 'Ethereum Mainnet'; + case 'sepolia': + return 'Ethereum Sepolia'; + case 'base': + return 'Base'; + case 'base-sepolia': + return 'Base Sepolia'; + } +} + +function resolveSettlementTransaction(receipt: PreservationReceipt): string | null { + if (receipt.payment.transaction) { + return receipt.payment.transaction; + } + + return extractTransactionFromResponse(receipt.payment.response); +} + +function resolvePreservationExpiration( + receipt: PreservationReceipt, + quote?: PreservationQuote, +): string | null { + if (receipt.expiresAt) { + return receipt.expiresAt; + } + + return quote?.expiresAt ?? null; +} + +function resolveManifestGatewayUrl( + receipt: PreservationReceipt, + gatewayUrl?: string, +): string | null { + if (receipt.manifestGatewayUrl) { + return receipt.manifestGatewayUrl; + } + + if (!gatewayUrl) { + return null; + } + + const normalizedGatewayUrl = gatewayUrl.replace(/\/+$/, ''); + return `${normalizedGatewayUrl}/ipfs/${receipt.manifestCid}`; +} + +function resolvePreservedAssetGatewayUrl( + asset: PreservationAsset, + gatewayUrl?: string, +): string | null { + if (asset.gatewayUrl) { + return asset.gatewayUrl; + } + + if (!gatewayUrl || !asset.cid) { + return null; + } + + const normalizedGatewayUrl = gatewayUrl.replace(/\/+$/, ''); + return `${normalizedGatewayUrl}/ipfs/${asset.cid}`; +} + +function formatPinnedAssetLabel(asset: PreservationAsset): string { + const label = asset.filename || asset.assetId; + return asset.role ? `${asset.role} (${label})` : label; +} + +function extractTransactionFromResponse(response: unknown): string | null { + if (!response || typeof response !== 'object') { + return null; + } + + const candidates = [ + (response as Record).transaction, + (response as Record).transactionHash, + (response as Record).txHash, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + + return null; +} diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 33a55c8..8f5e1f9 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -60,11 +60,24 @@ export function configureCommand(): Command { if (opts.rpcUrl) { config.chains[chain]!.rpcUrl = opts.rpcUrl; } + } + + if ( + opts.defaultChain || + opts.chain + ) { writeConfig(config); - console.log(`Configuration updated for chain: ${chain}`); } - if (!opts.show && !opts.defaultChain && !opts.chain) { + if (opts.chain) { + console.log(`Configuration updated for chain: ${opts.chain}`); + } + + if ( + !opts.show && + !opts.defaultChain && + !opts.chain + ) { cmd.help(); } }); diff --git a/src/contracts/addresses.ts b/src/contracts/addresses.ts index f889c68..24b9545 100644 --- a/src/contracts/addresses.ts +++ b/src/contracts/addresses.ts @@ -107,3 +107,13 @@ export function getContractAddresses(chain: SupportedChain): ContractSet { export function isSupportedChain(value: string): value is SupportedChain { return (supportedChains as readonly string[]).includes(value); } + +export function supportedChainFromChainId(chainId: number): SupportedChain | undefined { + for (const [chain, id] of Object.entries(chainIds)) { + if (id === chainId) { + return chain as SupportedChain; + } + } + + return undefined; +} diff --git a/src/data-access/client.ts b/src/data-access/client.ts index 58e2915..201126a 100644 --- a/src/data-access/client.ts +++ b/src/data-access/client.ts @@ -2,7 +2,7 @@ import createClient, { type Middleware } from 'openapi-fetch'; import type { paths } from './schema.js'; import { RareApiError } from './errors.js'; -const DEFAULT_BASE_URL = 'https://rare-api-8426-784573620320.us-east1.run.app'; +export const DEFAULT_BASE_URL = 'https://api.superrare.com'; const errorMiddleware: Middleware = { async onResponse({ response, request }) { diff --git a/src/index.ts b/src/index.ts index 8ee62ab..867a736 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { importCommand } from './commands/import.js'; import { offerCommand } from './commands/offer.js'; import { listingCommand } from './commands/listing.js'; import { currenciesCommand } from './commands/currencies.js'; +import { backupCommand } from './commands/backup.js'; import { setJsonMode } from './output.js'; const program = new Command(); @@ -39,6 +40,7 @@ program.addCommand(importCommand()); program.addCommand(offerCommand()); program.addCommand(listingCommand()); program.addCommand(currenciesCommand()); +program.addCommand(backupCommand()); program.parseAsync(process.argv).catch((err) => { // Only print here if not already handled (printContractError calls process.exit) diff --git a/src/sdk/backup-resolver.ts b/src/sdk/backup-resolver.ts new file mode 100644 index 0000000..89facb4 --- /dev/null +++ b/src/sdk/backup-resolver.ts @@ -0,0 +1,616 @@ +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; +import type { Address, PublicClient } from 'viem'; +import { isAddress } from 'viem'; +import { tokenAbi } from '../contracts/abis/token.js'; +import { chainIds, supportedChainFromChainId, type SupportedChain } from '../contracts/addresses.js'; +import { + DEFAULT_PRESERVATION_GATEWAY_URL, + DEFAULT_PRESERVATION_MAX_BYTES, + type PreservationAssetDescriptor, + type TokenPreservationSource, +} from './backup-service.js'; + +const TEXT_DECODER = new TextDecoder(); + +const EXTENSION_MIME_TYPES: Record = { + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.glb': 'model/gltf-binary', + '.gltf': 'model/gltf+json', + '.html': 'text/html', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', +}; + +const CID_V0_REGEX = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/; +const CID_V1_REGEX = + /^(b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,})$/; + +type MetadataCandidate = { + role: string; + uri: string; +}; + +type ResolvedUri = { + originalUri: string; + normalizedUri: string; + fetchUrl: string | null; +}; + +type DownloadedAsset = { + originalUri: string; + normalizedUri: string; + fetchUrl: string | null; + bytes: Uint8Array; + mimeType: string; +}; + +export interface ResolveTokenPreservationParams { + publicClient: PublicClient; + chain: SupportedChain; + contract?: Address; + tokenId?: bigint | number | string; + universalTokenId?: string; + gatewayUrl?: string; + maxBytes?: number; + fetchImpl?: typeof fetch; +} + +export interface StagedPreservationAsset extends PreservationAssetDescriptor { + bytes: Uint8Array; +} + +export interface ResolvedTokenPreservation { + source: TokenPreservationSource; + metadata: unknown; + metadataText: string; + assets: StagedPreservationAsset[]; + billableBytes: number; +} + +export interface ParsedUniversalTokenId { + chain: SupportedChain; + contract: Address; + tokenId: bigint; + universalTokenId: string; +} + +export function parseUniversalTokenId(value: string): ParsedUniversalTokenId { + const match = /^(\d+)-(0x[a-fA-F0-9]{40})-(\d+)$/.exec(value.trim()); + if (!match) { + throw new Error( + 'universalTokenId must use the format "--", e.g. "1-0xabc...-123".' + ); + } + + const chainId = Number.parseInt(match[1], 10); + const chain = supportedChainFromChainId(chainId); + if (!chain) { + throw new Error(`Unsupported chain id in universalTokenId: ${chainId}`); + } + + return { + chain, + contract: match[2] as Address, + tokenId: BigInt(match[3]), + universalTokenId: `${chainId}-${match[2].toLowerCase()}-${match[3]}`, + }; +} + +export async function resolveTokenPreservation( + params: ResolveTokenPreservationParams, +): Promise { + const gatewayUrl = normalizeGatewayUrl(params.gatewayUrl ?? DEFAULT_PRESERVATION_GATEWAY_URL); + const maxBytes = params.maxBytes ?? DEFAULT_PRESERVATION_MAX_BYTES; + const fetchImpl = params.fetchImpl ?? fetch; + + const source = await resolveSource(params); + const metadataDownload = await downloadAsset({ + role: 'metadata', + uri: source.tokenUri, + gatewayUrl, + fetchImpl, + }); + + const metadataText = TEXT_DECODER.decode(metadataDownload.bytes); + let metadata: unknown; + try { + metadata = JSON.parse(metadataText); + } catch (error) { + throw new Error( + `Token metadata at "${source.tokenUri}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const stagedAssets: StagedPreservationAsset[] = [ + toStagedAsset({ + assetId: 'asset_0000', + role: 'metadata', + originalUri: metadataDownload.originalUri, + bytes: metadataDownload.bytes, + mimeType: metadataDownload.mimeType || 'application/json', + }), + ]; + + let totalBytes = stagedAssets[0].size; + assertWithinByteCap(totalBytes, maxBytes); + + const metadataBaseUri = metadataDownload.normalizedUri; + const candidates = collectMetadataCandidates(metadata); + const seenUris = new Set(); + let assetIndex = 1; + + for (const candidate of candidates) { + const resolvedUri = resolveUri(candidate.uri, gatewayUrl, metadataBaseUri); + if (seenUris.has(resolvedUri.normalizedUri)) { + continue; + } + seenUris.add(resolvedUri.normalizedUri); + + const downloaded = await downloadAsset({ + role: candidate.role, + uri: resolvedUri.originalUri, + gatewayUrl, + fetchImpl, + metadataBaseUri, + }); + + const staged = toStagedAsset({ + assetId: `asset_${String(assetIndex).padStart(4, '0')}`, + role: candidate.role, + originalUri: downloaded.originalUri, + bytes: downloaded.bytes, + mimeType: downloaded.mimeType, + }); + + stagedAssets.push(staged); + totalBytes += staged.size; + assertWithinByteCap(totalBytes, maxBytes); + assetIndex += 1; + } + + return { + source, + metadata, + metadataText, + assets: stagedAssets, + billableBytes: totalBytes, + }; +} + +async function resolveSource( + params: ResolveTokenPreservationParams, +): Promise { + let chain = params.chain; + let contract = params.contract; + let tokenId = toBigInt(params.tokenId, 'tokenId'); + + if (params.universalTokenId) { + const parsed = parseUniversalTokenId(params.universalTokenId); + chain = parsed.chain; + contract = parsed.contract; + tokenId = parsed.tokenId; + } + + if (!contract || !isAddress(contract)) { + throw new Error('contract must be a valid EVM address'); + } + if (tokenId === undefined) { + throw new Error('tokenId is required'); + } + + const tokenUri = await params.publicClient.readContract({ + address: contract, + abi: tokenAbi, + functionName: 'tokenURI', + args: [tokenId], + }); + + return { + chain, + chainId: chainIds[chain], + contractAddress: contract, + tokenId: tokenId.toString(), + universalTokenId: `${chainIds[chain]}-${contract.toLowerCase()}-${tokenId.toString()}`, + tokenUri, + }; +} + +function collectMetadataCandidates(metadata: unknown): MetadataCandidate[] { + if (!metadata || typeof metadata !== 'object') { + return []; + } + + const root = metadata as Record; + const candidates: MetadataCandidate[] = []; + + pushStringCandidate(candidates, 'image', root.image); + pushStringCandidate(candidates, 'image_url', root.image_url); + pushStringCandidate(candidates, 'animation_url', root.animation_url); + pushStringCandidate(candidates, 'video', root.video); + pushStringCandidate(candidates, 'audio', root.audio); + pushStringCandidate(candidates, 'model', root.model); + pushStringCandidate(candidates, 'background_image', root.background_image); + + const media = root.media; + if (media && typeof media === 'object') { + const mediaRecord = media as Record; + pushStringCandidate(candidates, 'media.uri', mediaRecord.uri); + pushStringCandidate(candidates, 'media.url', mediaRecord.url); + } + + const properties = root.properties; + if (properties && typeof properties === 'object') { + const propertiesRecord = properties as Record; + pushStringCandidate(candidates, 'properties.image', propertiesRecord.image); + pushStringCandidate(candidates, 'properties.video', propertiesRecord.video); + pushStringCandidate(candidates, 'properties.audio', propertiesRecord.audio); + pushStringCandidate(candidates, 'properties.model', propertiesRecord.model); + + const files = propertiesRecord.files; + if (Array.isArray(files)) { + for (const file of files) { + if (!file || typeof file !== 'object') continue; + const record = file as Record; + pushStringCandidate(candidates, 'properties.files', record.uri); + pushStringCandidate(candidates, 'properties.files', record.url); + } + } + } + + collectNestedAbsoluteIpfsCandidates(metadata, null, candidates); + + return candidates; +} + +function collectNestedAbsoluteIpfsCandidates( + value: unknown, + role: string | null, + target: MetadataCandidate[], +): void { + if (typeof value === 'string') { + if (!role) return; + pushAbsoluteIpfsCandidate(target, role, value); + return; + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => { + collectNestedAbsoluteIpfsCandidates(entry, role ? `${role}[${index}]` : `[${index}]`, target); + }); + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, entry] of Object.entries(value as Record)) { + collectNestedAbsoluteIpfsCandidates(entry, role ? `${role}.${key}` : key, target); + } +} + +function pushAbsoluteIpfsCandidate(target: MetadataCandidate[], role: string, value: unknown): void { + if (typeof value !== 'string') return; + const trimmed = value.trim(); + if (!trimmed) return; + if (!looksLikeSupportedAbsolutePreservationUri(trimmed)) return; + target.push({ role, uri: trimmed }); +} + +function pushStringCandidate(target: MetadataCandidate[], role: string, value: unknown): void { + if (typeof value !== 'string') return; + const trimmed = value.trim(); + if (!trimmed) return; + if (role === 'external_url') return; + target.push({ role, uri: trimmed }); +} + +async function downloadAsset(opts: { + role: string; + uri: string; + gatewayUrl: string; + fetchImpl: typeof fetch; + metadataBaseUri?: string | null; +}): Promise { + const resolved = resolveUri(opts.uri, opts.gatewayUrl, opts.metadataBaseUri ?? null); + if (!resolved.fetchUrl) { + throw new Error(`Unable to resolve "${opts.uri}" for preservation.`); + } + + const response = await opts.fetchImpl(resolved.fetchUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch ${opts.role} from "${resolved.originalUri}" (${response.status} ${response.statusText || 'Unknown Error'})` + ); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const contentType = response.headers.get('content-type'); + + return { + originalUri: resolved.originalUri, + normalizedUri: resolved.normalizedUri, + fetchUrl: resolved.fetchUrl, + bytes, + mimeType: normalizeMimeType(contentType) ?? inferMimeTypeFromPath(resolved.fetchUrl), + }; +} + +function resolveUri(rawValue: string, gatewayUrl: string, metadataBaseUri?: string | null): ResolvedUri { + const canonicalUri = canonicalizeIpfsUri(rawValue, metadataBaseUri); + return toIpfsResolvedUri(extractIpfsPathFromCanonicalUri(canonicalUri), gatewayUrl); +} + +function normalizeIpfsPath(value: string): string { + const normalized = value.replace(/^\/+/, '').replace(/^ipfs\//, ''); + if (!normalized) { + throw new Error(`Invalid IPFS URI: "ipfs://${value}"`); + } + return normalized; +} + +function normalizeGatewayUrl(value: string): string { + return value.replace(/\/+$/, ''); +} + +function canonicalizeIpfsUri(rawValue: string, metadataBaseUri?: string | null): string { + const value = rawValue.trim(); + if (!value) { + throw new Error('Encountered an empty metadata URI while resolving preservation assets.'); + } + + if (value.startsWith('data:')) { + throw new Error( + 'Preservation only supports CID-backed IPFS URIs. Embedded data: URIs are not eligible for preservation.' + ); + } + + if (value.startsWith('ar://')) { + throw new Error( + `Preservation only supports CID-backed IPFS URIs. Found Arweave URI "${value}". Preservation does not rewrite tokenURI or metadata references, so ar:// assets are not eligible.` + ); + } + + const absoluteUri = tryCanonicalizeAbsoluteIpfsUri(value); + if (absoluteUri) { + return absoluteUri; + } + + if (hasUriScheme(value)) { + throw unsupportedPreservationUri(value); + } + + if (metadataBaseUri) { + return resolveRelativeIpfsUri(value, metadataBaseUri); + } + + throw unsupportedPreservationUri(value); +} + +function looksLikeSupportedAbsolutePreservationUri(value: string): boolean { + try { + return tryCanonicalizeAbsoluteIpfsUri(value) !== null; + } catch { + return false; + } +} + +function tryCanonicalizeAbsoluteIpfsUri(value: string): string | null { + if (value.startsWith('ipfs://')) { + return `ipfs://${normalizeIpfsPath(stripQueryAndFragment(value.slice('ipfs://'.length)))}`; + } + + if (/^https?:\/\//i.test(value)) { + try { + const ipfsPath = extractIpfsPathFromHttpUrl(new URL(value)); + return ipfsPath ? `ipfs://${ipfsPath}` : null; + } catch { + return null; + } + } + + const bareIpfsPath = extractIpfsPathFromBareValue(value); + return bareIpfsPath ? `ipfs://${bareIpfsPath}` : null; +} + +function resolveRelativeIpfsUri(value: string, metadataBaseUri: string): string { + const metadataBasePath = extractIpfsPathFromCanonicalUri(metadataBaseUri); + const baseSegments = metadataBasePath.split('/').filter(Boolean); + const resolvedSegments = [...(baseSegments.length > 1 ? baseSegments.slice(0, -1) : baseSegments)]; + const relativePath = stripQueryAndFragment(value).replace(/^\/+/, ''); + + if (!relativePath) { + throw unsupportedPreservationUri(value); + } + + for (const segment of relativePath.split('/')) { + if (!segment || segment === '.') { + continue; + } + + if (segment === '..') { + if (resolvedSegments.length > 1) { + resolvedSegments.pop(); + } + continue; + } + + resolvedSegments.push(segment); + } + + return `ipfs://${normalizeIpfsPath(resolvedSegments.join('/'))}`; +} + +function hasUriScheme(value: string): boolean { + return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value); +} + +function stripQueryAndFragment(value: string): string { + return value.replace(/[?#].*$/, ''); +} + +function extractIpfsPathFromBareValue(value: string): string | null { + const candidate = stripQueryAndFragment(value).replace(/^\/+/, ''); + if (!candidate) { + return null; + } + + const normalized = normalizeIpfsPath(candidate); + const [cid] = normalized.split('/'); + return cid && isLikelyCid(cid) ? normalized : null; +} + +function isLikelyCid(value: string): boolean { + return CID_V0_REGEX.test(value) || CID_V1_REGEX.test(value); +} + +function extractIpfsPathFromHttpUrl(url: URL): string | null { + const pathMatch = /^\/ipfs\/(.+)$/.exec(url.pathname); + if (pathMatch) { + return normalizeIpfsPath(pathMatch[1]); + } + + const subdomainMatch = /^([^.]+)\.ipfs\./i.exec(url.hostname); + if (subdomainMatch) { + const pathSuffix = url.pathname.replace(/^\/+/, ''); + return normalizeIpfsPath( + pathSuffix.length > 0 ? `${subdomainMatch[1]}/${pathSuffix}` : subdomainMatch[1] + ); + } + + return null; +} + +function extractIpfsPathFromCanonicalUri(uri: string): string { + if (!uri.startsWith('ipfs://')) { + throw new Error(`Expected an ipfs:// URI, received "${uri}".`); + } + + return normalizeIpfsPath(uri.slice('ipfs://'.length)); +} + +function toIpfsResolvedUri(path: string, gatewayUrl: string): ResolvedUri { + return { + originalUri: `ipfs://${path}`, + normalizedUri: `ipfs://${path}`, + fetchUrl: `${gatewayUrl}/ipfs/${path}`, + }; +} + +function unsupportedPreservationUri(value: string): Error { + return new Error( + `Preservation only supports CID-backed IPFS URIs. Found "${value}". Use ipfs:///..., a bare /... path, or an IPFS gateway URL like https://ipfs.io/ipfs//...` + ); +} + +function toStagedAsset(opts: { + assetId: string; + role: string; + originalUri: string; + bytes: Uint8Array; + mimeType: string; +}): StagedPreservationAsset { + return { + assetId: opts.assetId, + role: opts.role, + originalUri: opts.originalUri, + filename: inferFilename(opts.originalUri, opts.role, opts.mimeType), + mimeType: opts.mimeType, + size: opts.bytes.byteLength, + sha256: sha256Hex(opts.bytes), + bytes: opts.bytes, + }; +} + +function inferFilename(uri: string, role: string, mimeType: string): string { + if (!uri.startsWith('data:')) { + const candidate = inferFilenameFromUri(uri); + if (candidate) return candidate; + } + + const extension = extensionFromMimeType(mimeType); + const safeRole = role.replace(/[^a-zA-Z0-9._-]+/g, '-'); + return extension ? `${safeRole}${extension}` : `${safeRole}.bin`; +} + +function inferFilenameFromUri(uri: string): string | undefined { + try { + if (uri.startsWith('ipfs://')) { + const path = normalizeIpfsPath(uri.slice('ipfs://'.length)); + const lastSegment = path.split('/').pop(); + return lastSegment && lastSegment !== path ? lastSegment : undefined; + } + + if (uri.startsWith('ar://')) { + const path = uri.slice('ar://'.length).replace(/^\/+/, ''); + return path.split('/').pop() || undefined; + } + + const parsed = new URL(uri); + const lastSegment = basename(parsed.pathname); + return lastSegment && lastSegment !== '/' ? lastSegment : undefined; + } catch { + return undefined; + } +} + +function normalizeMimeType(value: string | null): string | undefined { + if (!value) return undefined; + const [mimeType] = value.split(';'); + return mimeType?.trim() || undefined; +} + +function inferMimeTypeFromPath(uri: string): string { + const path = uri.toLowerCase(); + const extension = Object.keys(EXTENSION_MIME_TYPES).find((candidate) => path.endsWith(candidate)); + return extension ? EXTENSION_MIME_TYPES[extension] : 'application/octet-stream'; +} + +function extensionFromMimeType(mimeType: string): string | undefined { + const entry = Object.entries(EXTENSION_MIME_TYPES).find(([, candidate]) => candidate === mimeType); + return entry?.[0]; +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function assertWithinByteCap(totalBytes: number, maxBytes: number): void { + if (totalBytes > maxBytes) { + throw new Error( + `Preservation payload is ${totalBytes} bytes, which exceeds the configured cap of ${maxBytes} bytes.` + ); + } +} + +function toBigInt(value: bigint | number | string | undefined, field: string): bigint | undefined { + if (value === undefined) return undefined; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative integer`); + } + return BigInt(value); + } + + try { + const parsed = BigInt(value); + if (parsed < 0n) { + throw new Error(`${field} must be a non-negative integer`); + } + return parsed; + } catch { + throw new Error(`${field} must be a non-negative integer`); + } +} diff --git a/src/sdk/backup-service.ts b/src/sdk/backup-service.ts new file mode 100644 index 0000000..af1ff7c --- /dev/null +++ b/src/sdk/backup-service.ts @@ -0,0 +1,762 @@ +import type { Address, Hash } from 'viem'; +import { chainIds, type SupportedChain } from '../contracts/addresses.js'; +import { DEFAULT_BASE_URL } from '../data-access/client.js'; + +export const DEFAULT_PRESERVATION_GATEWAY_URL = 'https://superrare.myfilebase.com'; +export const DEFAULT_PRESERVATION_MAX_BYTES = 1_073_741_824; +export const DEFAULT_PRESERVATION_SERVICE_URL = DEFAULT_BASE_URL; +export const RARE_RATE_PER_BYTE_ATOMIC = 69_690_000_000n; +const FINALIZE_POLL_INTERVAL_MS = 1_000; +const FINALIZE_POLL_TIMEOUT_MS = 300_000; +const PAYMENT_STATUS_POLL_INTERVAL_MS = 1_000; + +export interface TokenPreservationSource { + chain: SupportedChain; + chainId: number; + contractAddress: Address; + tokenId: string; + universalTokenId: string; + tokenUri: string; +} + +export interface PreservationAssetDescriptor { + assetId: string; + role: string; + originalUri: string; + filename: string; + mimeType: string; + size: number; + sha256: string; +} + +export interface PreservationAsset extends PreservationAssetDescriptor { + cid?: string; + ipfsUrl?: string; + gatewayUrl?: string; +} + +export interface PreservationPaymentOption { + scheme: string; + network: `eip155:${number}`; + asset: Address; + payTo: Address; + amount: string; + maxTimeoutSeconds: number; + extra?: Record | null; +} + +export interface PreservationPaymentSummary { + paymentIdentifier?: string; + network: string; + tokenAddress: Address; + tokenAmount: string; + payerAddress: Address | null; + transaction?: Hash | string; + settledAt?: string; + response?: unknown; +} + +export interface QuoteTokenPreservationRequest { + source: TokenPreservationSource; + assets: PreservationAssetDescriptor[]; + preferredPaymentChain?: SupportedChain; +} + +export interface PreservationQuote { + quoteId: string; + expiresAt: string; + billableBytes: number; + tokenAmount: string; + ratePerByteAtomic: string; + source: TokenPreservationSource; + assets: PreservationAssetDescriptor[]; + acceptedPayments: PreservationPaymentOption[]; +} + +export interface PreservationUploadTarget { + assetId: string; + uploadTransport: 'google-cloud-storage-xml-multipart'; + partSizeBytes: number; + uploadParts: PreservationUploadPartTarget[]; + completeUrl: string; + completeMethod: 'POST'; +} + +export interface PreservationUploadPartTarget { + partNumber: number; + uploadUrl: string; + method: 'PUT'; +} + +export interface PreservationUploadSession { + quoteId: string; + uploadToken: string; + expiresAt: string; + uploadTargets: PreservationUploadTarget[]; +} + +export interface PreservationReceipt { + receiptId: string; + quoteId: string; + expiresAt?: string; + manifestCid: string; + manifestIpfsUrl: string; + manifestGatewayUrl?: string; + billableBytes: number; + payment: PreservationPaymentSummary; + assets: PreservationAsset[]; + source: TokenPreservationSource; + createdAt: string; +} + +export type PreservationQuoteStatus = + | 'quoted' + | 'paid' + | 'uploaded' + | 'finalized' + | 'expired' + | 'rejected' + | (string & {}); + +export type PreservationPaymentLifecycleStatus = + | 'not_started' + | 'pending' + | 'settled' + | (string & {}); + +export interface PreservationQuotePaymentStatus { + quoteId: string; + quoteStatus: PreservationQuoteStatus; + expiresAt: string; + paymentStatus: PreservationPaymentLifecycleStatus; + payment: PreservationPaymentSummary | null; +} + +export type PreservationFinalizeJobState = + | 'queued' + | 'processing' + | 'completed' + | 'failed'; + +export type PreservationFinalizeProgressPhase = + | 'queued' + | 'processing' + | 'resolving_settlement' + | 'pinning_assets' + | 'pinning_manifest' + | 'persisting_receipt' + | 'completed' + | 'failed' + | (string & {}); + +export interface PreservationFinalizeJobStatus { + jobId: string | null; + quoteId: string; + status: PreservationFinalizeJobState; + progressPhase: PreservationFinalizeProgressPhase | null; + attempts: number; + submittedAt: string; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + receipt: PreservationReceipt | null; +} + +export interface PreservationUploadProgress { + phase: 'asset-started' | 'part-completed' | 'asset-completed'; + assetId: string; + assetIndex: number; + assetCount: number; + partNumber: number | null; + partCount: number | null; + uploadedBytes: number; + totalBytes: number; +} + +export interface QuoteTokenPreservationOptions { + serviceUrl: string; + request: QuoteTokenPreservationRequest; + fetchImpl?: typeof fetch; +} + +export interface CreatePreservationUploadSessionOptions { + serviceUrl: string; + quoteId: string; + fetchImpl: typeof fetch; + statusFetchImpl?: typeof fetch; + onPaymentStatusUpdate?: (status: PreservationQuotePaymentStatus) => void; +} + +export interface FinalizeTokenPreservationOptions { + serviceUrl: string; + quoteId: string; + uploadToken: string; + fetchImpl?: typeof fetch; + onStatusUpdate?: (status: PreservationFinalizeJobStatus) => void; +} + +export interface UploadAssetLike { + assetId: string; + bytes: Uint8Array; + mimeType: string; +} + +export class PreservationServiceError extends Error { + readonly status: number; + readonly body?: unknown; + + constructor(message: string, status: number, body?: unknown) { + super(message); + this.name = 'PreservationServiceError'; + this.status = status; + this.body = body; + } +} + +export function paymentNetworkForChain(chain: SupportedChain): `eip155:${number}` { + return `eip155:${chainIds[chain]}` as const; +} + +export async function quoteTokenPreservation(opts: QuoteTokenPreservationOptions): Promise { + const response = await (opts.fetchImpl ?? fetch)(serviceUrl('/v1/preservations/quotes', opts.serviceUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(opts.request), + }); + + return normalizePreservationQuote(await parseServiceJson(response, 'Failed to quote preservation')); +} + +export async function createPreservationUploadSession( + opts: CreatePreservationUploadSessionOptions, +): Promise { + const paymentStatusPoller = + opts.onPaymentStatusUpdate + ? startQuotePaymentStatusPolling({ + serviceUrl: opts.serviceUrl, + quoteId: opts.quoteId, + fetchImpl: opts.statusFetchImpl ?? fetch, + onStatusUpdate: opts.onPaymentStatusUpdate, + }) + : null; + + try { + const response = await opts.fetchImpl(serviceUrl(`/v1/preservations/quotes/${encodeURIComponent(opts.quoteId)}/upload-session`, opts.serviceUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + + return parseServiceJson(response, 'Failed to create preservation upload session'); + } finally { + paymentStatusPoller?.stop(); + } +} + +export async function uploadPreservationAssets( + serviceBaseUrl: string, + uploadSession: PreservationUploadSession, + assets: UploadAssetLike[], + fetchImpl: typeof fetch = fetch, + onProgress?: (progress: PreservationUploadProgress) => void, +): Promise { + const targets = new Map(uploadSession.uploadTargets.map((target) => [target.assetId, target])); + + for (const [assetIndex, asset] of assets.entries()) { + const target = targets.get(asset.assetId); + if (!target) { + throw new Error(`No upload target returned for asset "${asset.assetId}".`); + } + + if (target.uploadTransport !== 'google-cloud-storage-xml-multipart') { + throw new Error( + `Unsupported upload transport "${target.uploadTransport}" for asset "${asset.assetId}".`, + ); + } + + const sortedUploadParts = sortUploadParts(target.uploadParts); + const expectedPartCount = getMultipartPartCount(asset.bytes.byteLength, target.partSizeBytes); + if (sortedUploadParts.length !== expectedPartCount) { + throw new Error( + `Upload target for asset "${asset.assetId}" returned ${sortedUploadParts.length} part URLs; expected ${expectedPartCount}.`, + ); + } + + onProgress?.({ + phase: 'asset-started', + assetId: asset.assetId, + assetIndex, + assetCount: assets.length, + partNumber: null, + partCount: sortedUploadParts.length, + uploadedBytes: 0, + totalBytes: asset.bytes.byteLength, + }); + + let uploadedBytes = 0; + for (const uploadPart of sortedUploadParts) { + const partOffset = (uploadPart.partNumber - 1) * target.partSizeBytes; + const partBytes = asset.bytes.subarray( + partOffset, + Math.min(partOffset + target.partSizeBytes, asset.bytes.byteLength), + ); + if (partBytes.byteLength === 0) { + throw new Error( + `Upload target for asset "${asset.assetId}" included an empty part ${uploadPart.partNumber}.`, + ); + } + + const response = await fetchImpl( + resolveUploadTargetUrl(uploadPart.uploadUrl, serviceBaseUrl), + { + method: uploadPart.method, + body: new Uint8Array(partBytes), + }, + ); + + if (!response.ok) { + throw new PreservationServiceError( + `Upload failed for asset "${asset.assetId}" part ${uploadPart.partNumber}/${sortedUploadParts.length} with status ${response.status}`, + response.status, + await safeJson(response), + ); + } + + uploadedBytes += partBytes.byteLength; + onProgress?.({ + phase: 'part-completed', + assetId: asset.assetId, + assetIndex, + assetCount: assets.length, + partNumber: uploadPart.partNumber, + partCount: sortedUploadParts.length, + uploadedBytes, + totalBytes: asset.bytes.byteLength, + }); + } + + const completeResponse = await fetchImpl( + resolveUploadTargetUrl(target.completeUrl, serviceBaseUrl), + { + method: target.completeMethod, + }, + ); + + if (!completeResponse.ok) { + throw new PreservationServiceError( + `Upload completion failed for asset "${asset.assetId}" with status ${completeResponse.status}`, + completeResponse.status, + await safeJson(completeResponse), + ); + } + + onProgress?.({ + phase: 'asset-completed', + assetId: asset.assetId, + assetIndex, + assetCount: assets.length, + partNumber: sortedUploadParts.length, + partCount: sortedUploadParts.length, + uploadedBytes, + totalBytes: asset.bytes.byteLength, + }); + } +} + +export async function finalizeTokenPreservation( + opts: FinalizeTokenPreservationOptions, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const response = await fetchImpl( + serviceUrl(`/v1/preservations/quotes/${encodeURIComponent(opts.quoteId)}/finalize`, opts.serviceUrl), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uploadToken: opts.uploadToken }), + }, + ); + + const initialStatus = normalizeFinalizeJobStatus( + await parseServiceJson(response, 'Failed to finalize preservation'), + ); + return await waitForFinalizeReceipt({ + initialStatus, + serviceUrl: opts.serviceUrl, + fetchImpl, + onStatusUpdate: opts.onStatusUpdate, + }); +} + +async function parseServiceJson(response: Response, fallbackMessage: string): Promise { + const body = await safeJson(response); + if (!response.ok) { + const message = + extractErrorMessage(body) ?? + `${fallbackMessage} (${response.status} ${response.statusText || 'Unknown Error'})`; + throw new PreservationServiceError(message, response.status, body); + } + + return body as T; +} + +async function safeJson(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function extractErrorMessage(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined; + const maybeError = (body as Record).error; + return typeof maybeError === 'string' ? maybeError : undefined; +} + +function normalizePreservationQuote(body: unknown): PreservationQuote { + if (!body || typeof body !== 'object') { + throw new Error('Failed to quote preservation: invalid response body'); + } + + const record = body as Record; + const tokenAmount = normalizeTokenAmount(record.tokenAmount, 'quote'); + return { + ...(record as unknown as PreservationQuote), + tokenAmount, + }; +} + +function normalizePreservationReceipt(body: unknown): PreservationReceipt { + if (!body || typeof body !== 'object') { + throw new Error('Failed to finalize preservation: invalid response body'); + } + + const record = body as Record; + const payment = + record.payment && typeof record.payment === 'object' + ? normalizePreservationPaymentSummary(record.payment as Record) + : undefined; + + return { + ...(record as unknown as PreservationReceipt), + ...(payment ? { payment } : {}), + }; +} + +function normalizePreservationQuotePaymentStatus( + body: unknown, +): PreservationQuotePaymentStatus { + if (!body || typeof body !== 'object') { + throw new Error('Failed to fetch preservation payment status: invalid response body'); + } + + const record = body as Record; + const payment = + record.payment && typeof record.payment === 'object' + ? normalizePreservationPaymentSummary(record.payment as Record) + : null; + + return { + ...(record as unknown as PreservationQuotePaymentStatus), + quoteStatus: normalizePreservationQuoteStatus(record.quoteStatus), + paymentStatus: normalizePreservationPaymentLifecycleStatus(record.paymentStatus), + payment, + }; +} + +function normalizeFinalizeJobStatus(body: unknown): PreservationFinalizeJobStatus { + if (!body || typeof body !== 'object') { + throw new Error('Failed to finalize preservation: invalid finalize job response'); + } + + const record = body as Record; + const status = normalizeFinalizeJobState(record.status); + const progressPhase = normalizeFinalizeProgressPhase(record.progressPhase); + const receipt = + record.receipt && typeof record.receipt === 'object' + ? normalizePreservationReceipt(record.receipt) + : null; + + return { + ...(record as unknown as PreservationFinalizeJobStatus), + status, + progressPhase, + receipt, + }; +} + +function normalizeFinalizeJobState(value: unknown): PreservationFinalizeJobState { + switch (value) { + case 'queued': + case 'processing': + case 'completed': + case 'failed': + return value; + default: + throw new Error('Failed to finalize preservation: invalid finalize job status'); + } +} + +function normalizeFinalizeProgressPhase( + value: unknown, +): PreservationFinalizeProgressPhase | null { + if (value == null) { + return null; + } + + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function normalizePreservationQuoteStatus(value: unknown): PreservationQuoteStatus { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('Failed to fetch preservation payment status: invalid quote status'); + } + + return value as PreservationQuoteStatus; +} + +function normalizePreservationPaymentLifecycleStatus( + value: unknown, +): PreservationPaymentLifecycleStatus { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('Failed to fetch preservation payment status: invalid payment status'); + } + + return value as PreservationPaymentLifecycleStatus; +} + +function normalizePreservationPaymentSummary( + payment: Record, +): PreservationPaymentSummary { + const tokenAmount = normalizeTokenAmount(payment.tokenAmount, 'payment'); + return { + ...(payment as unknown as PreservationPaymentSummary), + tokenAmount, + }; +} + +function normalizeTokenAmount( + value: unknown, + context: string, +): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Failed to parse preservation ${context}: missing tokenAmount`); + } + + return value; +} + +async function waitForFinalizeReceipt(opts: { + initialStatus: PreservationFinalizeJobStatus; + serviceUrl: string; + fetchImpl: typeof fetch; + onStatusUpdate?: (status: PreservationFinalizeJobStatus) => void; +}): Promise { + let jobStatus = opts.initialStatus; + opts.onStatusUpdate?.(jobStatus); + + if (jobStatus.status === 'completed') { + return requireFinalizeReceipt(jobStatus); + } + + if (jobStatus.status === 'failed') { + throw buildFinalizeFailure(jobStatus); + } + + if (!jobStatus.jobId) { + throw new Error( + 'Failed to finalize preservation: seller returned a queued finalize job without a jobId', + ); + } + + const jobId = jobStatus.jobId; + const deadline = Date.now() + FINALIZE_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await delay(FINALIZE_POLL_INTERVAL_MS); + jobStatus = await getFinalizeJobStatus({ + serviceUrl: opts.serviceUrl, + jobId, + fetchImpl: opts.fetchImpl, + }); + opts.onStatusUpdate?.(jobStatus); + + if (jobStatus.status === 'completed') { + return requireFinalizeReceipt(jobStatus); + } + + if (jobStatus.status === 'failed') { + throw buildFinalizeFailure(jobStatus); + } + } + + throw new Error( + `Preservation finalization timed out after ${Math.round(FINALIZE_POLL_TIMEOUT_MS / 1000)} seconds (job ${jobId}).`, + ); +} + +async function getFinalizeJobStatus(opts: { + serviceUrl: string; + jobId: string; + fetchImpl: typeof fetch; +}): Promise { + const response = await opts.fetchImpl( + serviceUrl( + `/v1/preservations/finalize-jobs/${encodeURIComponent(opts.jobId)}`, + opts.serviceUrl, + ), + ); + return normalizeFinalizeJobStatus( + await parseServiceJson( + response, + 'Failed to fetch preservation finalize job status', + ), + ); +} + +function requireFinalizeReceipt( + jobStatus: PreservationFinalizeJobStatus, +): PreservationReceipt { + if (jobStatus.receipt) { + return jobStatus.receipt; + } + + throw new Error( + 'Failed to finalize preservation: completed finalize job did not include a receipt', + ); +} + +function buildFinalizeFailure(jobStatus: PreservationFinalizeJobStatus): Error { + const suffix = jobStatus.jobId ? ` (job ${jobStatus.jobId})` : ''; + const detail = jobStatus.errorMessage ? `: ${jobStatus.errorMessage}` : ''; + return new Error(`Preservation finalization failed${suffix}${detail}`); +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getQuotePaymentStatus(opts: { + serviceUrl: string; + quoteId: string; + fetchImpl: typeof fetch; +}): Promise { + const response = await opts.fetchImpl( + serviceUrl( + `/v1/preservations/quotes/${encodeURIComponent(opts.quoteId)}/payment-status`, + opts.serviceUrl, + ), + ); + + return normalizePreservationQuotePaymentStatus( + await parseServiceJson( + response, + 'Failed to fetch preservation payment status', + ), + ); +} + +function startQuotePaymentStatusPolling(opts: { + serviceUrl: string; + quoteId: string; + fetchImpl: typeof fetch; + onStatusUpdate: (status: PreservationQuotePaymentStatus) => void; +}): { stop: () => void } { + let stopped = false; + let timer: ReturnType | null = null; + let previousPaymentStatus: string | null = null; + + const scheduleNextPoll = (): void => { + if (stopped) { + return; + } + + timer = setTimeout(() => { + void pollOnce(); + }, PAYMENT_STATUS_POLL_INTERVAL_MS); + }; + + const pollOnce = async (): Promise => { + if (stopped) { + return; + } + + try { + const status = await getQuotePaymentStatus({ + serviceUrl: opts.serviceUrl, + quoteId: opts.quoteId, + fetchImpl: opts.fetchImpl, + }); + + if (stopped) { + return; + } + + if (status.paymentStatus !== previousPaymentStatus) { + previousPaymentStatus = status.paymentStatus; + opts.onStatusUpdate(status); + } + + if (status.paymentStatus === 'settled') { + return; + } + } catch { + return; + } + + scheduleNextPoll(); + }; + + scheduleNextPoll(); + + return { + stop(): void { + stopped = true; + if (timer !== null) { + clearTimeout(timer); + } + }, + }; +} + +function serviceUrl(pathname: string, rawBaseUrl: string): string { + const baseUrl = new URL(rawBaseUrl); + return new URL(pathname, baseUrl).toString(); +} + +function resolveUploadTargetUrl(targetUrl: string, rawBaseUrl: string): string { + try { + return new URL(targetUrl).toString(); + } catch { + return serviceUrl(targetUrl, rawBaseUrl); + } +} + +function sortUploadParts( + uploadParts: PreservationUploadPartTarget[], +): PreservationUploadPartTarget[] { + const sortedUploadParts = [...uploadParts].sort( + (left, right) => left.partNumber - right.partNumber, + ); + + for (const [index, uploadPart] of sortedUploadParts.entries()) { + const expectedPartNumber = index + 1; + if (uploadPart.partNumber !== expectedPartNumber) { + throw new Error( + `Upload session returned non-consecutive part numbers; expected ${expectedPartNumber}, received ${uploadPart.partNumber}.`, + ); + } + } + + return sortedUploadParts; +} + +function getMultipartPartCount(totalBytes: number, partSizeBytes: number): number { + if (!Number.isInteger(partSizeBytes) || partSizeBytes <= 0) { + throw new Error(`Invalid preservation multipart part size: ${partSizeBytes}`); + } + + return Math.ceil(totalBytes / partSizeBytes); +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index ec797ec..a4a6dfa 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -9,7 +9,12 @@ import { parseEventLogs, maxUint256, } from 'viem'; -import { getContractAddresses, chainIds, type SupportedChain } from '../contracts/addresses.js'; +import { + getContractAddresses, + chainIds, + supportedChainFromChainId, + type SupportedChain, +} from '../contracts/addresses.js'; import { factoryAbi } from '../contracts/abis/factory.js'; import { tokenAbi } from '../contracts/abis/token.js'; import { auctionAbi } from '../contracts/abis/auction.js'; @@ -38,6 +43,20 @@ import { type UserProfile, type Pagination, } from './api.js'; +import { + createPreservationUploadSession, + finalizeTokenPreservation, + quoteTokenPreservation as quoteTokenPreservationApi, + uploadPreservationAssets, + paymentNetworkForChain, + type PreservationFinalizeJobStatus, + type PreservationQuotePaymentStatus, + type PreservationUploadProgress, + type PreservationQuote, + type PreservationReceipt, +} from './backup-service.js'; +import { parseUniversalTokenId, resolveTokenPreservation } from './backup-resolver.js'; +import { createX402PaymentFetch } from './x402-client.js'; const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' as const; @@ -245,6 +264,35 @@ export interface TokenInfo { tokenUri: string; } +export type BackupPublicClientResolver = (chain: SupportedChain) => PublicClient; + +export interface QuoteTokenPreservationParams { + serviceUrl: string; + contract?: Address; + tokenId?: IntegerInput; + universalTokenId?: string; + sourceChain?: SupportedChain; + paymentChain?: SupportedChain; + gatewayUrl?: string; + maxBytes?: number; + fetchImpl?: typeof fetch; + publicClientResolver?: BackupPublicClientResolver; +} + +export interface PreserveTokenParams extends QuoteTokenPreservationParams { + paymentWalletClient?: WalletClient; + paymentRpcUrl?: string; + paymentFetch?: typeof fetch; + onUploadProgress?: (progress: PreservationUploadProgress) => void; + onPaymentStatusUpdate?: (status: PreservationQuotePaymentStatus) => void; + onFinalizeStatusUpdate?: (status: PreservationFinalizeJobStatus) => void; +} + +export interface PreserveTokenResult { + quote: PreservationQuote; + receipt: PreservationReceipt; +} + export interface RareClient { chain: SupportedChain; chainId: number; @@ -296,6 +344,10 @@ export interface RareClient { upload(buffer: Uint8Array, filename: string): Promise; pinMetadata(opts: PinMetadataParams): Promise; }; + backup: { + quoteTokenPreservation(params: QuoteTokenPreservationParams): Promise; + preserveToken(params: PreserveTokenParams): Promise; + }; import: { erc721(params: ImportErc721Params): Promise; }; @@ -384,6 +436,72 @@ function toWei(value: AmountInput): bigint { return parseEther(String(value)); } +function resolveBackupSourceChain(params: QuoteTokenPreservationParams, defaultChain: SupportedChain): SupportedChain { + if (params.universalTokenId) { + return parseUniversalTokenId(params.universalTokenId).chain; + } + + return params.sourceChain ?? defaultChain; +} + +function resolveBackupPublicClient( + config: RareClientConfig, + params: QuoteTokenPreservationParams, + sourceChain: SupportedChain, +): PublicClient { + if (sourceChain === resolveChainFromPublicClient(config.publicClient)) { + return config.publicClient; + } + + const resolved = params.publicClientResolver?.(sourceChain); + if (!resolved) { + throw new Error( + `No public client available for "${sourceChain}". Pass params.publicClientResolver(chain) to use backup flows across chains.` + ); + } + + return resolved; +} + +function resolveBackupPaymentAccount( + config: RareClientConfig, + params: PreserveTokenParams, + paymentChain: SupportedChain, +): { account: WalletAccount; rpcUrl: string } { + const walletClient = params.paymentWalletClient ?? config.walletClient; + if (!walletClient) { + throw new Error('paymentWalletClient is required for preservation payments.'); + } + + const account = walletClient.account; + if (!account) { + throw new Error('paymentWalletClient must include an account for preservation payments.'); + } + + const configuredChain = walletClient.chain?.id ? supportedChainFromChainId(walletClient.chain.id) : undefined; + if (configuredChain && configuredChain !== paymentChain) { + throw new Error( + `paymentWalletClient is configured for "${configuredChain}", but preservation payment chain is "${paymentChain}".` + ); + } + + const rpcUrl = params.paymentRpcUrl ?? extractRpcUrl(walletClient); + if (!rpcUrl) { + throw new Error( + `No RPC URL available for preservation payment chain "${paymentChain}". Pass params.paymentRpcUrl.` + ); + } + + return { account, rpcUrl }; +} + +function extractRpcUrl(walletClient: WalletClient): string | undefined { + const transport = walletClient.transport as + | { url?: string; value?: { url?: string }; config?: { url?: string } } + | undefined; + return transport?.url ?? transport?.value?.url ?? transport?.config?.url; +} + export function createRareClient(config: RareClientConfig): RareClient { const { publicClient } = config; const chain = resolveChainFromPublicClient(publicClient); @@ -1004,6 +1122,121 @@ export function createRareClient(config: RareClientConfig): RareClient { return pinMetadataApi(opts); }, }, + backup: { + async quoteTokenPreservation(params) { + const sourceChain = resolveBackupSourceChain(params, chain); + const paymentChain = params.paymentChain ?? sourceChain; + const sourcePublicClient = resolveBackupPublicClient(config, params, sourceChain); + const resolved = await resolveTokenPreservation({ + publicClient: sourcePublicClient, + chain: sourceChain, + contract: params.contract, + tokenId: params.tokenId, + universalTokenId: params.universalTokenId, + gatewayUrl: params.gatewayUrl, + maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, + }); + + return quoteTokenPreservationApi({ + serviceUrl: params.serviceUrl, + request: { + source: resolved.source, + assets: resolved.assets.map(({ assetId, role, originalUri, filename, mimeType, size, sha256 }) => ({ + assetId, + role, + originalUri, + filename, + mimeType, + size, + sha256, + })), + preferredPaymentChain: paymentChain, + }, + fetchImpl: params.fetchImpl, + }); + }, + + async preserveToken(params) { + const sourceChain = resolveBackupSourceChain(params, chain); + const paymentChain = params.paymentChain ?? sourceChain; + const sourcePublicClient = resolveBackupPublicClient(config, params, sourceChain); + const resolved = await resolveTokenPreservation({ + publicClient: sourcePublicClient, + chain: sourceChain, + contract: params.contract, + tokenId: params.tokenId, + universalTokenId: params.universalTokenId, + gatewayUrl: params.gatewayUrl, + maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, + }); + + const quote = await quoteTokenPreservationApi({ + serviceUrl: params.serviceUrl, + request: { + source: resolved.source, + assets: resolved.assets.map(({ assetId, role, originalUri, filename, mimeType, size, sha256 }) => ({ + assetId, + role, + originalUri, + filename, + mimeType, + size, + sha256, + })), + preferredPaymentChain: paymentChain, + }, + fetchImpl: params.fetchImpl, + }); + + const selectedNetwork = paymentNetworkForChain(paymentChain); + if (!quote.acceptedPayments.some((option) => option.network === selectedNetwork)) { + throw new Error( + `Preservation service does not advertise a payment option for "${paymentChain}" (${selectedNetwork}).` + ); + } + + const { account, rpcUrl } = resolveBackupPaymentAccount(config, params, paymentChain); + const paymentFetch = + params.paymentFetch ?? + createX402PaymentFetch({ + paymentChain, + rpcUrl, + account, + fetchImpl: params.fetchImpl, + }); + + const uploadSession = await createPreservationUploadSession({ + serviceUrl: params.serviceUrl, + quoteId: quote.quoteId, + fetchImpl: paymentFetch, + statusFetchImpl: params.fetchImpl, + onPaymentStatusUpdate: params.onPaymentStatusUpdate, + }); + + await uploadPreservationAssets( + params.serviceUrl, + uploadSession, + resolved.assets, + params.fetchImpl, + params.onUploadProgress, + ); + + const receipt = await finalizeTokenPreservation({ + serviceUrl: params.serviceUrl, + quoteId: quote.quoteId, + uploadToken: uploadSession.uploadToken, + fetchImpl: params.fetchImpl, + onStatusUpdate: params.onFinalizeStatusUpdate, + }); + + return { + quote, + receipt, + }; + }, + }, import: { async erc721(params) { const owner = params.owner ?? config.account ?? config.walletClient?.account?.address; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 91d91df..1c83516 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,5 +1,11 @@ export { createRareClient } from './client.js'; -export type { RareClient, RareClientConfig } from './client.js'; +export type { + RareClient, + RareClientConfig, + QuoteTokenPreservationParams, + PreserveTokenParams, + PreserveTokenResult, +} from './client.js'; export type { CollectionSearchParams, ImportErc721Params, @@ -21,8 +27,20 @@ export { viemChains, getContractAddresses, isSupportedChain, + supportedChainFromChainId, } from '../contracts/addresses.js'; export type { SupportedChain } from '../contracts/addresses.js'; export { factoryAbi } from '../contracts/abis/factory.js'; export { auctionAbi } from '../contracts/abis/auction.js'; export { tokenAbi } from '../contracts/abis/token.js'; +export type { + PreservationAsset, + PreservationFinalizeJobStatus, + PreservationFinalizeProgressPhase, + PreservationPaymentLifecycleStatus, + PreservationPaymentOption, + PreservationQuote, + PreservationQuotePaymentStatus, + PreservationReceipt, + TokenPreservationSource, +} from './backup-service.js'; diff --git a/src/sdk/x402-client.ts b/src/sdk/x402-client.ts new file mode 100644 index 0000000..121afe0 --- /dev/null +++ b/src/sdk/x402-client.ts @@ -0,0 +1,144 @@ +import { ExactEvmScheme } from '@x402/evm'; +import { x402Client, x402HTTPClient } from '@x402/fetch'; +import type { WalletClient } from 'viem'; +import { chainIds, type SupportedChain } from '../contracts/addresses.js'; + +type WalletAccount = NonNullable; +type PaymentRequiredResponse = ReturnType; + +const MAX_PAYMENT_REQUIRED_RETRIES = 3; + +export interface CreateX402PaymentFetchOptions { + paymentChain: SupportedChain; + rpcUrl: string; + account: WalletAccount; + fetchImpl?: typeof fetch; +} + +export function createX402PaymentFetch(opts: CreateX402PaymentFetchOptions): typeof fetch { + const client = new x402Client(); + const chainId = chainIds[opts.paymentChain]; + const baseFetch = opts.fetchImpl ?? fetch; + + client + .register(`eip155:${chainId}`, new ExactEvmScheme(opts.account, { [chainId]: { rpcUrl: opts.rpcUrl } })) + .onBeforePaymentCreation(async ({ paymentRequired }) => { + if (!paymentRequired.extensions || typeof paymentRequired.extensions !== 'object') { + return; + } + + const extension = paymentRequired.extensions['payment-identifier']; + if (!isPaymentIdentifierDeclaration(extension)) { + return; + } + + paymentRequired.extensions = { + ...paymentRequired.extensions, + 'payment-identifier': { + ...extension, + info: { + ...extension.info, + id: generatePaymentIdentifier(), + }, + }, + }; + }); + + const httpClient = new x402HTTPClient(client); + return async (input, init) => { + const requestTemplate = new Request(input, init); + let response = await baseFetch(requestTemplate.clone()); + let retries = 0; + + // Some sellers refresh time-sensitive payment requirements, such as + // maxTimeoutSeconds, between the initial 402 challenge and the paid retry. + // Regenerate the payment payload from the latest challenge a few times + // before giving up so the upload-session flow survives that drift. + while (response.status === 402 && retries < MAX_PAYMENT_REQUIRED_RETRIES) { + const paymentRequired = await readPaymentRequiredResponse(httpClient, response); + if (!paymentRequired) { + return response; + } + + const hookHeaders = await httpClient.handlePaymentRequired(paymentRequired); + if (hookHeaders) { + const hookRequest = requestTemplate.clone(); + for (const [key, value] of Object.entries(hookHeaders)) { + hookRequest.headers.set(key, value); + } + + const hookResponse = await baseFetch(hookRequest); + if (hookResponse.status !== 402) { + return hookResponse; + } + + const refreshedPaymentRequired = await readPaymentRequiredResponse(httpClient, hookResponse); + if (refreshedPaymentRequired) { + response = hookResponse; + } + } + + const latestPaymentRequired = await readPaymentRequiredResponse(httpClient, response); + if (!latestPaymentRequired) { + return response; + } + + const paymentPayload = await client.createPaymentPayload(latestPaymentRequired); + const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload); + const paidRequest = requestTemplate.clone(); + + if (paidRequest.headers.has('PAYMENT-SIGNATURE') || paidRequest.headers.has('X-PAYMENT')) { + throw new Error('Payment already attempted'); + } + + for (const [key, value] of Object.entries(paymentHeaders)) { + paidRequest.headers.set(key, value); + } + paidRequest.headers.set('Access-Control-Expose-Headers', 'PAYMENT-RESPONSE,X-PAYMENT-RESPONSE'); + + response = await baseFetch(paidRequest); + if (response.status !== 402) { + return response; + } + + retries += 1; + } + + return response; + }; +} + +async function readPaymentRequiredResponse( + httpClient: x402HTTPClient, + response: Response, +): Promise { + try { + const cloned = response.clone(); + let body: unknown; + + try { + const text = await cloned.text(); + if (text) { + body = JSON.parse(text) as unknown; + } + } catch { + body = undefined; + } + + return httpClient.getPaymentRequiredResponse((name) => response.headers.get(name), body); + } catch { + return null; + } +} + +function generatePaymentIdentifier(prefix = 'pres_'): string { + return `${prefix}${crypto.randomUUID().replace(/-/g, '')}`; +} + +function isPaymentIdentifierDeclaration( + value: unknown, +): value is { info: { required: boolean; id?: string } } { + if (!value || typeof value !== 'object') return false; + const info = (value as { info?: unknown }).info; + return Boolean(info && typeof info === 'object' && typeof (info as { required?: unknown }).required === 'boolean'); +} diff --git a/test/backup-command.test.mjs b/test/backup-command.test.mjs new file mode 100644 index 0000000..8d3d627 --- /dev/null +++ b/test/backup-command.test.mjs @@ -0,0 +1,695 @@ +import http from 'node:http'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { encodeAbiParameters } from 'viem'; + +const ROOT_DIR = fileURLToPath(new URL('..', import.meta.url)); +const DIST_CLI = path.join(ROOT_DIR, 'dist', 'index.js'); +const TEST_PRIVATE_KEY = `0x${'11'.repeat(32)}`; + +test('backup token refuses to preserve without confirmation in non-interactive mode', async () => { + const server = await startPreservationServer(); + const homeDir = createTempHome(); + + try { + writeRareConfig(homeDir, { + defaultChain: 'sepolia', + chains: { + sepolia: { + privateKey: TEST_PRIVATE_KEY, + rpcUrl: `${server.baseUrl}/rpc`, + }, + }, + preservation: {}, + }); + + const result = await runCli( + [ + 'backup', + 'token', + '--contract', + '0x3333333333333333333333333333333333333333', + '--token-id', + '7', + '--chain', + 'sepolia', + '--service-url', + server.baseUrl, + '--gateway', + server.baseUrl, + ], + { env: { HOME: homeDir } }, + ); + + assert.equal(result.code, 1); + assert.match(result.stdout, /Preservation quote:/); + assert.match(result.stdout, /Assets:\s+4/); + assert.match(result.stdout, /Amount:\s+0\.000000000000000123 RARE/); + assert.match( + result.stderr, + /Preservation payment requires confirmation, but no interactive terminal is available/ + ); + assert.equal(server.counters.quoteRequests, 1); + assert.equal(server.counters.uploadSessionRequests, 0); + assert.equal(server.counters.finalizeRequests, 0); + } finally { + server.close(); + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test('backup token preserves successfully with --yes in non-interactive mode', async () => { + const server = await startPreservationServer(); + const homeDir = createTempHome(); + + try { + writeRareConfig(homeDir, { + defaultChain: 'sepolia', + chains: { + sepolia: { + privateKey: TEST_PRIVATE_KEY, + rpcUrl: `${server.baseUrl}/rpc`, + }, + }, + preservation: {}, + }); + + const result = await runCli( + [ + 'backup', + 'token', + '--contract', + '0x3333333333333333333333333333333333333333', + '--token-id', + '7', + '--chain', + 'sepolia', + '--service-url', + server.baseUrl, + '--gateway', + server.baseUrl, + '--yes', + ], + { env: { HOME: homeDir } }, + ); + + assert.equal(result.code, 0); + assert.match(result.stdout, /Preservation quote:/); + assert.match(result.stdout, /Assets:\s+4/); + assert.match(result.stdout, /Preservation payment facilitation in progress\./); + assert.match(result.stdout, /Preservation payment settled\./); + assert.match(result.stdout, /Uploading quoted assets directly to preservation storage\.\.\./); + assert.match(result.stdout, /Waiting for preservation finalization\.\.\./); + assert.match(result.stdout, /Preservation finalize job queued/); + assert.match(result.stdout, /Preservation finalize job pinning manifest/); + assert.match(result.stdout, /Preservation finalize job completed/); + assert.match(result.stdout, /Preservation complete:/); + assert.doesNotMatch(result.stdout, /Record CID:/); + assert.doesNotMatch(result.stdout, /Record URI:/); + assert.doesNotMatch(result.stdout, /Record link:/); + assert.match(result.stdout, new RegExp(`Your Receipt:\\s+${escapeRegex(`${server.baseUrl}/ipfs/bafytest`)}`)); + assert.match(result.stdout, /Assets pinned:\s+4/); + assert.match(result.stdout, /Asset links:/); + assert.match(result.stdout, new RegExp(`metadata \\(metadata\\.json\\): ${escapeRegex(`${server.baseUrl}/cid0`)}`)); + assert.match(result.stdout, new RegExp(`image \\(image\\.png\\): ${escapeRegex(`${server.baseUrl}/cid1`)}`)); + assert.match(result.stdout, new RegExp(`media\\.uri \\(animation\\.mp4\\): ${escapeRegex(`${server.baseUrl}/cid2`)}`)); + assert.match(result.stdout, new RegExp(`properties\\.files \\(alt\\.bin\\): ${escapeRegex(`${server.baseUrl}/cid3`)}`)); + assert.match(result.stdout, /Receipt ID:\s+receipt_test/); + assert.equal(result.stderr, ''); + assert.equal(server.counters.quoteRequests, 1); + assert.equal(server.counters.uploadSessionRequests, 2); + assert.equal(server.counters.paymentStatusRequests, 2); + assert.equal(server.counters.finalizeRequests, 1); + assert.equal(server.counters.finalizeJobStatusRequests, 2); + assert.ok(server.counters.uploadRequests > 4); + assert.equal(server.counters.uploadCompleteRequests, 4); + } finally { + server.close(); + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test('backup token quote-only accepts source and payment chain IDs', async () => { + const server = await startPreservationServer(); + const homeDir = createTempHome(); + + try { + writeRareConfig(homeDir, { + chains: { + sepolia: { + rpcUrl: `${server.baseUrl}/rpc`, + }, + }, + preservation: {}, + }); + + const result = await runCli( + [ + 'backup', + 'token', + '--contract', + '0x3333333333333333333333333333333333333333', + '--token-id', + '7', + '--chain-id', + '11155111', + '--payment-chain-id', + '8453', + '--service-url', + server.baseUrl, + '--gateway', + server.baseUrl, + '--quote-only', + ], + { env: { HOME: homeDir } }, + ); + + assert.equal(result.code, 0); + assert.match(result.stdout, /Preservation quote:/); + assert.match(result.stdout, /Payment chain:\s+base/); + assert.equal(result.stderr, ''); + assert.equal(server.counters.quoteRequests, 1); + assert.equal(server.counters.uploadSessionRequests, 0); + assert.equal(server.counters.finalizeRequests, 0); + } finally { + server.close(); + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +function createTempHome() { + return mkdtempSync(path.join(os.tmpdir(), 'rare-cli-home-')); +} + +function writeRareConfig(homeDir, config) { + const configDir = path.join(homeDir, '.rare'); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, 'config.json'), JSON.stringify(config, null, 2), 'utf8'); +} + +function runCli(args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [DIST_CLI, ...args], { + cwd: ROOT_DIR, + env: { + ...process.env, + ...opts.env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + resolve({ + code, + stdout, + stderr, + }); + }); + + child.stdin.end(opts.input ?? ''); + }); +} + +async function startPreservationServer() { + let baseUrl = ''; + const uploaded = new Map(); + const pendingUploads = new Map(); + let quotedAssets = []; + const counters = { + quoteRequests: 0, + uploadSessionRequests: 0, + paymentStatusRequests: 0, + finalizeRequests: 0, + finalizeJobStatusRequests: 0, + uploadRequests: 0, + uploadCompleteRequests: 0, + }; + let paymentSettled = false; + const metadataCid = 'bafytestmetadata'; + const imagePath = `${metadataCid}/image.png`; + const mediaPath = `${metadataCid}/animation.mp4`; + const altPath = `${metadataCid}/alt.bin`; + const mediaBytes = Buffer.from('video-bytes'); + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + + if (url.pathname === '/rpc' && req.method === 'POST') { + const body = await readJson(req); + res.setHeader('content-type', 'application/json'); + + if (body.method === 'eth_chainId') { + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? 1, + result: '0xaa36a7', + }), + ); + return; + } + + if (body.method === 'eth_call') { + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? 1, + result: encodeAbiParameters( + [{ type: 'string' }], + [`ipfs://${metadataCid}/metadata.json`], + ), + }), + ); + return; + } + + res.statusCode = 400; + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? 1, + error: { + code: -32601, + message: `Unsupported RPC method: ${body.method}`, + }, + }), + ); + return; + } + + if (url.pathname === `/ipfs/${metadataCid}/metadata.json`) { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + image: 'image.png', + media: { + uri: 'animation.mp4', + mimeType: 'video/mp4', + }, + properties: { + files: [{ url: 'alt.bin' }], + }, + }), + ); + return; + } + + if (url.pathname === `/ipfs/${imagePath}`) { + res.setHeader('content-type', 'image/png'); + res.end(Buffer.from([0, 1, 2, 3])); + return; + } + + if (url.pathname === `/ipfs/${mediaPath}`) { + res.setHeader('content-type', 'video/mp4'); + res.end(mediaBytes); + return; + } + + if (url.pathname === `/ipfs/${altPath}`) { + res.setHeader('content-type', 'application/octet-stream'); + res.end(Buffer.from('alt')); + return; + } + + if (url.pathname === '/v1/preservations/quotes' && req.method === 'POST') { + counters.quoteRequests += 1; + const body = await readJson(req); + quotedAssets = body.assets; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + expiresAt: '2026-01-01T00:00:00.000Z', + billableBytes: body.assets.reduce((sum, asset) => sum + asset.size, 0), + tokenAmount: '123', + ratePerByteAtomic: '69690000000', + source: body.source, + assets: body.assets, + acceptedPayments: [ + { + scheme: 'exact', + network: 'eip155:11155111', + asset: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + payTo: '0x1111111111111111111111111111111111111111', + amount: '123', + maxTimeoutSeconds: 60, + extra: null, + }, + ], + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_test/upload-session' && req.method === 'POST') { + counters.uploadSessionRequests += 1; + + const paymentSignature = req.headers['payment-signature']; + if (!paymentSignature || Array.isArray(paymentSignature)) { + respondWithPaymentRequired(res, 60); + return; + } + + await delay(1_100); + paymentSettled = true; + await delay(1_100); + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + uploadToken: 'upload_token', + expiresAt: '2026-01-01T00:05:00.000Z', + uploadTargets: quotedAssets.map((asset) => + buildMultipartUploadTarget(baseUrl, 'upload_token', asset) + ), + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_test/payment-status' && req.method === 'GET') { + counters.paymentStatusRequests += 1; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + quoteStatus: paymentSettled ? 'paid' : 'quoted', + expiresAt: '2026-01-01T00:00:00.000Z', + paymentStatus: paymentSettled ? 'settled' : 'pending', + payment: paymentSettled + ? { + paymentIdentifier: 'pres_test', + network: 'eip155:11155111', + tokenAddress: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + tokenAmount: '123', + payerAddress: '0x2222222222222222222222222222222222222222', + transaction: '0xabc123', + settledAt: '2026-01-01T00:06:00.000Z', + } + : null, + }), + ); + return; + } + + const uploadMatch = url.pathname.match(/^\/upload\/([^/]+)\/parts\/(\d+)$/); + if (uploadMatch && req.method === 'PUT') { + counters.uploadRequests += 1; + const [, assetId, rawPartNumber] = uploadMatch; + storeMultipartUploadPart( + pendingUploads, + assetId, + Number.parseInt(rawPartNumber, 10), + await readBuffer(req), + ); + res.statusCode = 204; + res.end(); + return; + } + + const completeMatch = url.pathname.match( + /^\/v1\/preservations\/uploads\/upload_token\/([^/]+)\/complete$/, + ); + if (completeMatch && req.method === 'POST') { + counters.uploadCompleteRequests += 1; + const [, assetId] = completeMatch; + const completed = completeMultipartUpload(pendingUploads, uploaded, assetId); + if (!completed) { + res.statusCode = 409; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'upload_incomplete' })); + return; + } + + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + assetId, + quoteId: 'quote_test', + size: uploaded.get(assetId)?.length ?? 0, + sha256: 'verified', + verifiedAt: '2026-01-01T00:07:00.000Z', + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_test/finalize' && req.method === 'POST') { + counters.finalizeRequests += 1; + res.statusCode = 202; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + jobId: 'job_test', + status: 'queued', + progressPhase: 'queued', + attempts: 0, + submittedAt: '2026-01-01T00:07:00.000Z', + startedAt: null, + completedAt: null, + errorMessage: null, + receipt: null, + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/finalize-jobs/job_test' && req.method === 'GET') { + counters.finalizeJobStatusRequests += 1; + const isCompleted = counters.finalizeJobStatusRequests > 1; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + jobId: 'job_test', + quoteId: 'quote_test', + status: isCompleted ? 'completed' : 'processing', + progressPhase: isCompleted ? 'completed' : 'pinning_manifest', + attempts: 1, + submittedAt: '2026-01-01T00:07:00.000Z', + startedAt: '2026-01-01T00:07:00.000Z', + completedAt: isCompleted ? '2026-01-01T00:07:02.000Z' : null, + errorMessage: null, + receipt: isCompleted ? { + receiptId: 'receipt_test', + quoteId: 'quote_test', + expiresAt: '2026-01-01T00:10:00.000Z', + manifestCid: 'bafytest', + manifestIpfsUrl: 'ipfs://bafytest', + manifestGatewayUrl: `${baseUrl}/ipfs/bafytest`, + billableBytes: [...uploaded.values()].reduce((sum, buffer) => sum + buffer.length, 0), + payment: { + paymentIdentifier: 'pres_test', + network: 'eip155:11155111', + tokenAddress: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + tokenAmount: '123', + payerAddress: '0x2222222222222222222222222222222222222222', + transaction: '0xabc123', + settledAt: '2026-01-01T00:06:00.000Z', + }, + assets: [ + { + assetId: 'asset_0000', + role: 'metadata', + originalUri: `ipfs://${metadataCid}/metadata.json`, + filename: 'metadata.json', + mimeType: 'application/json', + size: uploaded.get('asset_0000')?.length ?? 0, + sha256: 'a', + cid: 'cid0', + ipfsUrl: 'ipfs://cid0', + gatewayUrl: `${baseUrl}/cid0`, + }, + { + assetId: 'asset_0001', + role: 'image', + originalUri: `ipfs://${imagePath}`, + filename: 'image.png', + mimeType: 'image/png', + size: uploaded.get('asset_0001')?.length ?? 0, + sha256: 'b', + cid: 'cid1', + ipfsUrl: 'ipfs://cid1', + gatewayUrl: `${baseUrl}/cid1`, + }, + { + assetId: 'asset_0002', + role: 'media.uri', + originalUri: `ipfs://${mediaPath}`, + filename: 'animation.mp4', + mimeType: 'video/mp4', + size: uploaded.get('asset_0002')?.length ?? 0, + sha256: 'c', + cid: 'cid2', + ipfsUrl: 'ipfs://cid2', + gatewayUrl: `${baseUrl}/cid2`, + }, + { + assetId: 'asset_0003', + role: 'properties.files', + originalUri: `ipfs://${altPath}`, + filename: 'alt.bin', + mimeType: 'application/octet-stream', + size: uploaded.get('asset_0003')?.length ?? 0, + sha256: 'd', + cid: 'cid3', + ipfsUrl: 'ipfs://cid3', + gatewayUrl: `${baseUrl}/cid3`, + }, + ], + source: { + chain: 'sepolia', + chainId: 11155111, + contractAddress: '0x3333333333333333333333333333333333333333', + tokenId: '7', + universalTokenId: '11155111-0x3333333333333333333333333333333333333333-7', + tokenUri: `ipfs://${metadataCid}/metadata.json`, + }, + createdAt: '2026-01-01T00:07:00.000Z', + } : null, + }), + ); + return; + } + + res.statusCode = 404; + res.end('not found'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; + + return { + baseUrl, + counters, + close() { + server.close(); + }, + }; +} + +function readBuffer(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function readJson(req) { + return JSON.parse((await readBuffer(req)).toString('utf8')); +} + +function respondWithPaymentRequired(res, maxTimeoutSeconds) { + res.statusCode = 402; + res.setHeader( + 'payment-required', + encodeBase64Json({ + x402Version: 2, + error: 'Payment required', + resource: { + url: 'http://127.0.0.1/upload-session', + description: 'Upload session', + mimeType: 'application/json', + }, + accepts: [ + { + scheme: 'exact', + network: 'eip155:11155111', + amount: '123', + asset: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds, + extra: { + assetTransferMethod: 'permit2', + name: 'SuperRare', + version: '1', + }, + }, + ], + extensions: { + 'payment-identifier': { + info: { + required: true, + }, + }, + }, + }), + ); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'payment_required' })); +} + +function encodeBase64Json(value) { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); +} + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildMultipartUploadTarget(baseUrl, uploadToken, asset, partSizeBytes = 4) { + const partCount = Math.ceil(asset.size / partSizeBytes); + return { + assetId: asset.assetId, + uploadTransport: 'google-cloud-storage-xml-multipart', + partSizeBytes, + uploadParts: Array.from({ length: partCount }, (_, index) => ({ + partNumber: index + 1, + uploadUrl: `${baseUrl}/upload/${asset.assetId}/parts/${index + 1}`, + method: 'PUT', + })), + completeUrl: `/v1/preservations/uploads/${uploadToken}/${asset.assetId}/complete`, + completeMethod: 'POST', + }; +} + +function storeMultipartUploadPart(pendingUploads, assetId, partNumber, buffer) { + const existingParts = pendingUploads.get(assetId) ?? new Map(); + existingParts.set(partNumber, buffer); + pendingUploads.set(assetId, existingParts); +} + +function completeMultipartUpload(pendingUploads, uploaded, assetId) { + const parts = pendingUploads.get(assetId); + if (!parts) { + return false; + } + + const assembled = Buffer.concat( + [...parts.entries()] + .sort(([leftPartNumber], [rightPartNumber]) => leftPartNumber - rightPartNumber) + .map(([, buffer]) => buffer), + ); + + uploaded.set(assetId, assembled); + pendingUploads.delete(assetId); + return true; +} diff --git a/test/backup-flow.test.mjs b/test/backup-flow.test.mjs new file mode 100644 index 0000000..2886ef4 --- /dev/null +++ b/test/backup-flow.test.mjs @@ -0,0 +1,802 @@ +import http from 'node:http'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createRareClient } from '../dist/client.js'; + +const TEST_PRIVATE_KEY = `0x${'11'.repeat(32)}`; + +test('quote and preserve an NFT through the hosted backup service contract', async () => { + const uploaded = new Map(); + const pendingUploads = new Map(); + let quotedAssets = []; + let baseUrl = ''; + const quoteExpiresAt = '2026-01-01T00:00:00.000Z'; + const uploadSessionExpiresAt = '2026-01-01T00:05:00.000Z'; + const receiptExpiresAt = '2026-01-01T00:10:00.000Z'; + const settledAt = '2026-01-01T00:06:00.000Z'; + const createdAt = '2026-01-01T00:07:00.000Z'; + const metadataCid = 'bafytestmetadata'; + const imagePath = `${metadataCid}/image.png`; + const mediaPath = `${metadataCid}/animation.mp4`; + const altPath = `${metadataCid}/alt.bin`; + const archiveCid = 'bafybeieoqgt4xroadj5ukoeukr537ri7kbg3gelzddjdudle4ygujxnk3q'; + const archivePath = `${archiveCid}/archive.glb`; + const mediaBytes = Buffer.from('video-bytes'); + const archiveBytes = Buffer.from('archive-bytes'); + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + + if (url.pathname === `/ipfs/${metadataCid}/metadata.json`) { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + image: 'image.png', + media: { + uri: 'animation.mp4', + mimeType: 'video/mp4', + }, + attachments: { + archive: archivePath, + }, + properties: { + files: [{ url: 'alt.bin' }], + }, + }), + ); + return; + } + + if (url.pathname === `/ipfs/${imagePath}`) { + res.setHeader('content-type', 'image/png'); + res.end(Buffer.from([0, 1, 2, 3])); + return; + } + + if (url.pathname === `/ipfs/${mediaPath}`) { + res.setHeader('content-type', 'video/mp4'); + res.end(mediaBytes); + return; + } + + if (url.pathname === `/ipfs/${altPath}`) { + res.setHeader('content-type', 'application/octet-stream'); + res.end(Buffer.from('alt')); + return; + } + + if (url.pathname === `/ipfs/${archivePath}`) { + res.setHeader('content-type', 'model/gltf-binary'); + res.end(archiveBytes); + return; + } + + if (url.pathname === '/v1/preservations/quotes' && req.method === 'POST') { + const body = await readJson(req); + quotedAssets = body.assets; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + expiresAt: quoteExpiresAt, + billableBytes: body.assets.reduce((sum, asset) => sum + asset.size, 0), + tokenAmount: '123', + ratePerByteAtomic: '69690000000', + source: body.source, + assets: body.assets, + acceptedPayments: [ + { + scheme: 'exact', + network: 'eip155:11155111', + asset: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + payTo: '0x1111111111111111111111111111111111111111', + amount: '123', + maxTimeoutSeconds: 60, + extra: { + assetTransferMethod: 'permit2', + name: 'SuperRare', + version: '1', + }, + }, + ], + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_test/upload-session' && req.method === 'POST') { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + uploadToken: 'upload_token', + expiresAt: uploadSessionExpiresAt, + uploadTargets: quotedAssets.map((asset) => + buildMultipartUploadTarget(baseUrl, 'upload_token', asset) + ), + }), + ); + return; + } + + const uploadMatch = url.pathname.match(/^\/upload\/([^/]+)\/parts\/(\d+)$/); + if (uploadMatch && req.method === 'PUT') { + const [, assetId, rawPartNumber] = uploadMatch; + storeMultipartUploadPart( + pendingUploads, + assetId, + Number.parseInt(rawPartNumber, 10), + await readBuffer(req), + ); + res.statusCode = 204; + res.end(); + return; + } + + const completeMatch = url.pathname.match( + /^\/v1\/preservations\/uploads\/upload_token\/([^/]+)\/complete$/, + ); + if (completeMatch && req.method === 'POST') { + const [, assetId] = completeMatch; + const completed = completeMultipartUpload(pendingUploads, uploaded, assetId); + if (!completed) { + res.statusCode = 409; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'upload_incomplete' })); + return; + } + + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + assetId, + quoteId: 'quote_test', + size: uploaded.get(assetId)?.length ?? 0, + sha256: 'verified', + verifiedAt: createdAt, + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_test/finalize' && req.method === 'POST') { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_test', + jobId: null, + status: 'completed', + attempts: 1, + submittedAt: createdAt, + startedAt: createdAt, + completedAt: createdAt, + errorMessage: null, + receipt: { + receiptId: 'receipt_test', + quoteId: 'quote_test', + expiresAt: receiptExpiresAt, + manifestCid: 'bafytest', + manifestIpfsUrl: 'ipfs://bafytest', + manifestGatewayUrl: `${baseUrl}/ipfs/bafytest`, + billableBytes: [...uploaded.values()].reduce((sum, buffer) => sum + buffer.length, 0), + payment: { + paymentIdentifier: 'pres_test', + network: 'eip155:11155111', + tokenAddress: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + tokenAmount: '123', + payerAddress: '0x2222222222222222222222222222222222222222', + transaction: '0xabc123', + settledAt, + }, + assets: [ + { + assetId: 'asset_0000', + role: 'metadata', + originalUri: `ipfs://${metadataCid}/metadata.json`, + filename: 'metadata.json', + mimeType: 'application/json', + size: uploaded.get('asset_0000')?.length ?? 0, + sha256: 'a', + cid: 'cid0', + ipfsUrl: 'ipfs://cid0', + gatewayUrl: `${baseUrl}/cid0`, + }, + { + assetId: 'asset_0001', + role: 'image', + originalUri: `ipfs://${imagePath}`, + filename: 'image.png', + mimeType: 'image/png', + size: uploaded.get('asset_0001')?.length ?? 0, + sha256: 'b', + cid: 'cid1', + ipfsUrl: 'ipfs://cid1', + gatewayUrl: `${baseUrl}/cid1`, + }, + { + assetId: 'asset_0002', + role: 'media.uri', + originalUri: `ipfs://${mediaPath}`, + filename: 'animation.mp4', + mimeType: 'video/mp4', + size: uploaded.get('asset_0002')?.length ?? 0, + sha256: 'c', + cid: 'cid2', + ipfsUrl: 'ipfs://cid2', + gatewayUrl: `${baseUrl}/cid2`, + }, + { + assetId: 'asset_0003', + role: 'properties.files', + originalUri: `ipfs://${altPath}`, + filename: 'alt.bin', + mimeType: 'application/octet-stream', + size: uploaded.get('asset_0003')?.length ?? 0, + sha256: 'd', + cid: 'cid3', + ipfsUrl: 'ipfs://cid3', + gatewayUrl: `${baseUrl}/cid3`, + }, + { + assetId: 'asset_0004', + role: 'attachments.archive', + originalUri: `ipfs://${archivePath}`, + filename: 'archive.glb', + mimeType: 'model/gltf-binary', + size: uploaded.get('asset_0004')?.length ?? 0, + sha256: 'e', + cid: 'cid4', + ipfsUrl: 'ipfs://cid4', + gatewayUrl: `${baseUrl}/cid4`, + }, + ], + source: { + chain: 'sepolia', + chainId: 11155111, + contractAddress: '0x3333333333333333333333333333333333333333', + tokenId: '7', + universalTokenId: '11155111-0x3333333333333333333333333333333333333333-7', + tokenUri: `${baseUrl}/ipfs/${metadataCid}/metadata.json`, + }, + createdAt, + }, + }), + ); + return; + } + + res.statusCode = 404; + res.end('not found'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const publicClient = { + chain: { id: 11155111 }, + readContract: async ({ functionName }) => { + if (functionName === 'tokenURI') { + return `${baseUrl}/ipfs/${metadataCid}/metadata.json`; + } + throw new Error(`unexpected contract read: ${functionName}`); + }, + }; + + const paymentWalletClient = { + account: { address: '0x2222222222222222222222222222222222222222' }, + chain: { id: 11155111 }, + transport: { url: `${baseUrl}/rpc` }, + }; + + const rare = createRareClient({ publicClient }); + + const quote = await rare.backup.quoteTokenPreservation({ + serviceUrl: baseUrl, + contract: '0x3333333333333333333333333333333333333333', + tokenId: '7', + sourceChain: 'sepolia', + gatewayUrl: baseUrl, + }); + + assert.equal(quote.quoteId, 'quote_test'); + assert.equal(quote.assets.length, 5); + assert.equal(quote.expiresAt, quoteExpiresAt); + assert.equal(quote.source.tokenUri, `${baseUrl}/ipfs/${metadataCid}/metadata.json`); + assert.deepEqual(quote.assets.map((asset) => asset.role), [ + 'metadata', + 'image', + 'media.uri', + 'properties.files', + 'attachments.archive', + ]); + assert.equal(quote.assets.find((asset) => asset.role === 'media.uri')?.size, mediaBytes.length); + assert.equal(quote.assets.find((asset) => asset.role === 'attachments.archive')?.size, archiveBytes.length); + assert.equal( + quote.assets.find((asset) => asset.role === 'attachments.archive')?.originalUri, + `ipfs://${archivePath}` + ); + + const result = await rare.backup.preserveToken({ + serviceUrl: baseUrl, + contract: '0x3333333333333333333333333333333333333333', + tokenId: '7', + sourceChain: 'sepolia', + paymentChain: 'sepolia', + paymentWalletClient, + paymentRpcUrl: `${baseUrl}/rpc`, + paymentFetch: fetch, + gatewayUrl: baseUrl, + }); + + assert.equal(result.receipt.receiptId, 'receipt_test'); + assert.equal(result.receipt.expiresAt, receiptExpiresAt); + assert.equal(result.receipt.manifestGatewayUrl, `${baseUrl}/ipfs/bafytest`); + assert.equal(result.receipt.assets.length, 5); + assert.equal(result.receipt.payment.transaction, '0xabc123'); + assert.equal(uploaded.size, 5); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test('rejects preservation for non-CID-backed token URIs', async () => { + const publicClient = { + chain: { id: 11155111 }, + readContract: async ({ functionName }) => { + if (functionName === 'tokenURI') { + return 'https://example.com/metadata.json'; + } + throw new Error(`unexpected contract read: ${functionName}`); + }, + }; + + const rare = createRareClient({ publicClient }); + + await assert.rejects( + () => + rare.backup.quoteTokenPreservation({ + serviceUrl: 'http://127.0.0.1:1', + contract: '0x3333333333333333333333333333333333333333', + tokenId: '7', + sourceChain: 'sepolia', + }), + /Preservation only supports CID-backed IPFS URIs/ + ); +}); + +test('retries upload-session payment when the seller refreshes the x402 challenge before the paid retry', async () => { + const uploaded = new Map(); + const pendingUploads = new Map(); + let quotedAssets = []; + let baseUrl = ''; + let uploadSessionRequests = 0; + let paidUploadSessionRequests = 0; + let paymentStatusRequests = 0; + let finalizeJobStatusRequests = 0; + let paymentSettled = false; + const paymentStatuses = []; + const finalizeProgressPhases = []; + const metadataCid = 'bafyx402metadata'; + const imagePath = `${metadataCid}/image.png`; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + + if (url.pathname === `/ipfs/${metadataCid}/metadata.json`) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ image: 'image.png' })); + return; + } + + if (url.pathname === `/ipfs/${imagePath}`) { + res.setHeader('content-type', 'image/png'); + res.end(Buffer.from([0, 1, 2, 3])); + return; + } + + if (url.pathname === '/v1/preservations/quotes' && req.method === 'POST') { + const body = await readJson(req); + quotedAssets = body.assets; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_retry', + expiresAt: '2026-01-01T00:00:00.000Z', + billableBytes: body.assets.reduce((sum, asset) => sum + asset.size, 0), + tokenAmount: '123', + ratePerByteAtomic: '69690000000', + source: body.source, + assets: body.assets, + acceptedPayments: [ + { + scheme: 'exact', + network: 'eip155:11155111', + asset: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + payTo: '0x1111111111111111111111111111111111111111', + amount: '123', + maxTimeoutSeconds: 60, + extra: { + assetTransferMethod: 'permit2', + name: 'SuperRare', + version: '1', + }, + }, + ], + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_retry/upload-session' && req.method === 'POST') { + uploadSessionRequests += 1; + + const paymentSignature = req.headers['payment-signature']; + if (!paymentSignature || Array.isArray(paymentSignature)) { + respondWithPaymentRequired(res, 60); + return; + } + + paidUploadSessionRequests += 1; + const payment = decodeBase64Json(paymentSignature); + const acceptedTimeout = payment?.accepted?.maxTimeoutSeconds; + + if (paidUploadSessionRequests === 1) { + assert.equal(acceptedTimeout, 60); + respondWithPaymentRequired(res, 59, false); + return; + } + + assert.equal(acceptedTimeout, 59); + await delay(1_100); + paymentSettled = true; + await delay(1_100); + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_retry', + uploadToken: 'upload_retry', + expiresAt: '2026-01-01T00:05:00.000Z', + uploadTargets: quotedAssets.map((asset) => + buildMultipartUploadTarget(baseUrl, 'upload_retry', asset) + ), + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_retry/payment-status' && req.method === 'GET') { + paymentStatusRequests += 1; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_retry', + quoteStatus: paymentSettled ? 'paid' : 'quoted', + expiresAt: '2026-01-01T00:00:00.000Z', + paymentStatus: paymentSettled ? 'settled' : 'pending', + payment: paymentSettled + ? { + paymentIdentifier: 'pres_retry', + network: 'eip155:11155111', + tokenAddress: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + tokenAmount: '123', + payerAddress: '0x2222222222222222222222222222222222222222', + transaction: '0xretry123', + settledAt: '2026-01-01T00:06:00.000Z', + } + : null, + }), + ); + return; + } + + const uploadMatch = url.pathname.match(/^\/upload\/([^/]+)\/parts\/(\d+)$/); + if (uploadMatch && req.method === 'PUT') { + const [, assetId, rawPartNumber] = uploadMatch; + storeMultipartUploadPart( + pendingUploads, + assetId, + Number.parseInt(rawPartNumber, 10), + await readBuffer(req), + ); + res.statusCode = 204; + res.end(); + return; + } + + const completeMatch = url.pathname.match( + /^\/v1\/preservations\/uploads\/upload_retry\/([^/]+)\/complete$/, + ); + if (completeMatch && req.method === 'POST') { + const [, assetId] = completeMatch; + const completed = completeMultipartUpload(pendingUploads, uploaded, assetId); + if (!completed) { + res.statusCode = 409; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'upload_incomplete' })); + return; + } + + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + assetId, + quoteId: 'quote_retry', + size: uploaded.get(assetId)?.length ?? 0, + sha256: 'verified', + verifiedAt: '2026-01-01T00:07:00.000Z', + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/quotes/quote_retry/finalize' && req.method === 'POST') { + res.statusCode = 202; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + quoteId: 'quote_retry', + jobId: 'job_retry', + status: 'queued', + progressPhase: 'queued', + attempts: 0, + submittedAt: '2026-01-01T00:07:00.000Z', + startedAt: null, + completedAt: null, + errorMessage: null, + receipt: null, + }), + ); + return; + } + + if (url.pathname === '/v1/preservations/finalize-jobs/job_retry' && req.method === 'GET') { + finalizeJobStatusRequests += 1; + res.setHeader('content-type', 'application/json'); + const isCompleted = finalizeJobStatusRequests > 1; + res.end( + JSON.stringify({ + jobId: 'job_retry', + quoteId: 'quote_retry', + status: isCompleted ? 'completed' : 'processing', + progressPhase: isCompleted ? 'completed' : 'pinning_manifest', + attempts: 1, + submittedAt: '2026-01-01T00:07:00.000Z', + startedAt: '2026-01-01T00:07:01.000Z', + completedAt: isCompleted ? '2026-01-01T00:07:02.000Z' : null, + errorMessage: null, + receipt: isCompleted ? { + receiptId: 'receipt_retry', + quoteId: 'quote_retry', + expiresAt: '2026-01-01T00:10:00.000Z', + manifestCid: 'bafyretry', + manifestIpfsUrl: 'ipfs://bafyretry', + manifestGatewayUrl: `${baseUrl}/ipfs/bafyretry`, + billableBytes: [...uploaded.values()].reduce((sum, buffer) => sum + buffer.length, 0), + payment: { + paymentIdentifier: 'pres_retry', + network: 'eip155:11155111', + tokenAddress: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + tokenAmount: '123', + payerAddress: '0x2222222222222222222222222222222222222222', + transaction: '0xretry123', + settledAt: '2026-01-01T00:06:00.000Z', + }, + assets: [ + { + assetId: 'asset_0000', + role: 'metadata', + originalUri: `ipfs://${metadataCid}/metadata.json`, + filename: 'metadata.json', + mimeType: 'application/json', + size: uploaded.get('asset_0000')?.length ?? 0, + sha256: 'a', + cid: 'cid0', + ipfsUrl: 'ipfs://cid0', + gatewayUrl: `${baseUrl}/cid0`, + }, + { + assetId: 'asset_0001', + role: 'image', + originalUri: `ipfs://${imagePath}`, + filename: 'image.png', + mimeType: 'image/png', + size: uploaded.get('asset_0001')?.length ?? 0, + sha256: 'b', + cid: 'cid1', + ipfsUrl: 'ipfs://cid1', + gatewayUrl: `${baseUrl}/cid1`, + }, + ], + source: { + chain: 'sepolia', + chainId: 11155111, + contractAddress: '0x3333333333333333333333333333333333333333', + tokenId: '7', + universalTokenId: '11155111-0x3333333333333333333333333333333333333333-7', + tokenUri: `ipfs://${metadataCid}/metadata.json`, + }, + createdAt: '2026-01-01T00:07:00.000Z', + } : null, + }), + ); + return; + } + + res.statusCode = 404; + res.end('not found'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const publicClient = { + chain: { id: 11155111 }, + readContract: async ({ functionName }) => { + if (functionName === 'tokenURI') { + return `ipfs://${metadataCid}/metadata.json`; + } + throw new Error(`unexpected contract read: ${functionName}`); + }, + }; + + const paymentWalletClient = { + account: privateKeyToAccount(TEST_PRIVATE_KEY), + chain: { id: 11155111 }, + transport: { url: `${baseUrl}/rpc` }, + }; + + const rare = createRareClient({ publicClient }); + const result = await rare.backup.preserveToken({ + serviceUrl: baseUrl, + contract: '0x3333333333333333333333333333333333333333', + tokenId: '7', + sourceChain: 'sepolia', + paymentChain: 'sepolia', + paymentWalletClient, + paymentRpcUrl: `${baseUrl}/rpc`, + gatewayUrl: baseUrl, + onPaymentStatusUpdate: (status) => { + paymentStatuses.push(status.paymentStatus); + }, + onFinalizeStatusUpdate: (status) => { + finalizeProgressPhases.push(status.progressPhase ?? status.status); + }, + }); + + assert.equal(result.receipt.receiptId, 'receipt_retry'); + assert.equal(uploadSessionRequests, 3); + assert.equal(paidUploadSessionRequests, 2); + assert.equal(paymentStatusRequests, 2); + assert.deepEqual(paymentStatuses, ['pending', 'settled']); + assert.equal(finalizeJobStatusRequests, 2); + assert.deepEqual(finalizeProgressPhases, ['queued', 'pinning_manifest', 'completed']); + assert.equal(uploaded.size, 2); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +function readBuffer(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +async function readJson(req) { + return JSON.parse((await readBuffer(req)).toString('utf8')); +} + +function respondWithPaymentRequired(res, maxTimeoutSeconds, includeJsonBody = true) { + res.statusCode = 402; + res.setHeader( + 'payment-required', + encodeBase64Json({ + x402Version: 2, + error: 'Payment required', + resource: { + url: 'http://127.0.0.1/upload-session', + description: 'Upload session', + mimeType: 'application/json', + }, + accepts: [ + { + scheme: 'exact', + network: 'eip155:11155111', + amount: '123', + asset: '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB', + payTo: '0x1111111111111111111111111111111111111111', + maxTimeoutSeconds, + extra: { + assetTransferMethod: 'permit2', + name: 'SuperRare', + version: '1', + }, + }, + ], + extensions: { + 'payment-identifier': { + info: { + required: true, + }, + }, + }, + }), + ); + + if (includeJsonBody) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'payment_required' })); + return; + } + + res.end(); +} + +function encodeBase64Json(value) { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); +} + +function decodeBase64Json(value) { + return JSON.parse(Buffer.from(value, 'base64').toString('utf8')); +} + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildMultipartUploadTarget(baseUrl, uploadToken, asset, partSizeBytes = 4) { + const partCount = Math.ceil(asset.size / partSizeBytes); + return { + assetId: asset.assetId, + uploadTransport: 'google-cloud-storage-xml-multipart', + partSizeBytes, + uploadParts: Array.from({ length: partCount }, (_, index) => ({ + partNumber: index + 1, + uploadUrl: `${baseUrl}/upload/${asset.assetId}/parts/${index + 1}`, + method: 'PUT', + })), + completeUrl: `/v1/preservations/uploads/${uploadToken}/${asset.assetId}/complete`, + completeMethod: 'POST', + }; +} + +function storeMultipartUploadPart(pendingUploads, assetId, partNumber, buffer) { + const existingParts = pendingUploads.get(assetId) ?? new Map(); + existingParts.set(partNumber, buffer); + pendingUploads.set(assetId, existingParts); +} + +function completeMultipartUpload(pendingUploads, uploaded, assetId) { + const parts = pendingUploads.get(assetId); + if (!parts) { + return false; + } + + const assembled = Buffer.concat( + [...parts.entries()] + .sort(([leftPartNumber], [rightPartNumber]) => leftPartNumber - rightPartNumber) + .map(([, buffer]) => buffer), + ); + + uploaded.set(assetId, assembled); + pendingUploads.delete(assetId); + return true; +}