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;
+}