diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index e3b0417ba0..09eed54d39 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -53,6 +53,7 @@ jobs: { echo "SERVER_EVM_ADDRESS=${{ secrets.SERVER_EVM_ADDRESS }}" echo "SERVER_SVM_ADDRESS=${{ secrets.SERVER_SVM_ADDRESS }}" + echo "SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=${{ secrets.SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY }}" echo "SERVER_AVM_ADDRESS=${{ secrets.SERVER_AVM_ADDRESS }}" echo "SERVER_APTOS_ADDRESS=${{ secrets.SERVER_APTOS_ADDRESS }}" echo "SERVER_CCD_ADDRESS=${{ secrets.SERVER_CCD_ADDRESS }}" @@ -94,7 +95,7 @@ jobs: echo "FACILITATOR_CARDANO_MNEMONIC=${{ secrets.FACILITATOR_CARDANO_MNEMONIC }}" echo "BLOCKFROST_PROJECT_ID=${{ secrets.BLOCKFROST_PROJECT_ID }}" echo "SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=${{ secrets.SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY }}" - echo "CLIENT_EVM_VOUCHER_SIGNER_PRIVATE_KEY=${{ secrets.CLIENT_EVM_VOUCHER_SIGNER_PRIVATE_KEY }}" + echo "CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY=${{ secrets.CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY }}" echo "TVM_PROVIDER=${{ secrets.TVM_PROVIDER }}" echo "TVM_TONAPI_API_KEY=${{ secrets.TVM_TONAPI_API_KEY }}" echo "TVM_TONCENTER_API_KEY=${{ secrets.TVM_TONCENTER_API_KEY }}" @@ -158,10 +159,29 @@ jobs: pnpm install --frozen-lockfile pnpm build + - name: Print wallet status + if: steps.families.outputs.configured == 'true' + run: pnpm wallet:status + - name: Setup E2E components if: steps.families.outputs.configured == 'true' run: ./setup.sh + # Create/fund the Swig smart wallet when SVM is in the run and persist + # SWIG_ACCOUNT_ADDRESS to e2e/.env for the test step (and later steps). + - name: Setup Swig smart wallet + if: steps.families.outputs.configured == 'true' && contains(steps.families.outputs.families, 'svm') + run: | + set -a + # shellcheck disable=SC1091 + source .env + set +a + # Only forward a custom RPC; empty SVM_TESTNET_RPC_URL falls back to devnet in swig-setup. + if [[ -n "${SVM_TESTNET_RPC_URL:-}" ]]; then + export SVM_RPC_URL="$SVM_TESTNET_RPC_URL" + fi + pnpm swig:setup + - name: Run minimized e2e tests (testnet) if: steps.families.outputs.configured == 'true' run: | diff --git a/contracts/evm/README.md b/contracts/evm/README.md index 0099e28e69..b1543e2262 100644 --- a/contracts/evm/README.md +++ b/contracts/evm/README.md @@ -23,7 +23,7 @@ Both contracts: | Contract | Address | |----------|---------| | x402ExactPermit2Proxy | `0x402085c248EeA27D92E8b30b2C58ed07f9E20001` | -| x402UptoPermit2Proxy | `0x402015c795ecb48A360bDC6e35a2EaEb313a0002` | +| x402UptoPermit2Proxy | `0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002` | **Batch settlement (CREATE2 vanity `0x4020…`)** @@ -41,7 +41,7 @@ Both contracts: | Chain | Exact | Upto | |-------|-------|------| -| Base Mainnet | [Deployed](https://basescan.org/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | — | +| Base Mainnet | [Deployed](https://basescan.org/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | [Deployed](https://basescan.org/address/0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002) | | Base Sepolia | [Deployed](https://sepolia.basescan.org/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | [Legacy\*](https://sepolia.basescan.org/address/0x402039b3d6E6BEC5A02c2C9fd937ac17A6940002) | **Batch settlement deployments** @@ -61,7 +61,7 @@ Both contracts: | Monad Mainnet | [Deployed](https://monadscan.com/address/0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003) | [Deployed](https://monadscan.com/address/0x4020806089470a89826cB9fB1f4059150b550004) | [Deployed](https://monadscan.com/address/0x4020425FAf3B746C082C2f942b4E5159887B0005) | > \*Older testnet deployments may use prior vanity salts; the canonical **Upto** address for -> CREATE2 deployments from this tree is `0x402015c7…313a0002` (see `forge script script/ComputeAddress.s.sol`). +> CREATE2 deployments from this tree is `0x4020A4f3…C240002` (see `forge script script/ComputeAddress.s.sol`). ## Prerequisites @@ -106,7 +106,7 @@ and `keccak256(initCode)`—not on who sends the transaction. ``` You should see: - Exact → `0x402085c248EeA27D92E8b30b2C58ed07f9E20001` - - Upto → `0x402015c795ecb48A360bDC6e35a2EaEb313a0002` + - Upto → `0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002` 3. **Check prerequisites on the target chain** - [Permit2](https://github.com/Uniswap/permit2) must be deployed at `0x000000000022D473030F116dDEE9F6B43aC78BA3` @@ -115,15 +115,29 @@ and `keccak256(initCode)`—not on who sends the transaction. 4. **Deploy** ```bash - export PRIVATE_KEY="your_private_key" + cast wallet import x402-deployment-signer --interactive forge script script/Deploy.s.sol \ --rpc-url \ + --account x402-deployment-signer \ + --sender \ --broadcast \ --verify ``` - The script automatically: + To deploy only the Upto proxy, select its deployment entry point: + + ```bash + forge script script/Deploy.s.sol \ + --sig "runUpto()" \ + --rpc-url \ + --account x402-deployment-signer \ + --sender \ + --broadcast \ + --verify + ``` + + The default `run()` entry point automatically: - Loads the pre-built initCode for Exact and compiler-derived initCode for Upto - Skips any contract already deployed at the expected address - Verifies `PERMIT2()` returns the correct address after deployment diff --git a/contracts/evm/script/ComputeAddress.s.sol b/contracts/evm/script/ComputeAddress.s.sol index 5df43e254a..ad674af4a0 100644 --- a/contracts/evm/script/ComputeAddress.s.sol +++ b/contracts/evm/script/ComputeAddress.s.sol @@ -43,8 +43,8 @@ contract ComputeAddress is Script { bytes32 constant DEFAULT_EXACT_SALT = 0x0000000000000000000000000000000000000000000000003000000007263b0e; /// @notice Default salt for x402UptoPermit2Proxy - /// @dev Vanity mined for address 0x402015c795ecb48a360bdc6e35a2eaeb313a0002 - bytes32 constant DEFAULT_UPTO_SALT = 0x0000000000000000000000000000000000000000000000000800000007e2e4de; + /// @dev Vanity mined for address 0x4020a4f3b7b90cca423b9fabcc0ce57c6c240002 + bytes32 constant DEFAULT_UPTO_SALT = 0x000000000000000000000000000000000000000000000000b000000001db633d; /// @notice Expected initCodeHash for x402ExactPermit2Proxy (pre-built, includes CBOR metadata) bytes32 constant EXACT_INIT_CODE_HASH = 0xe774d1d5a07218946ab54efe010b300481478b86861bb17d69c98a57f68a604c; diff --git a/contracts/evm/script/Deploy.s.sol b/contracts/evm/script/Deploy.s.sol index 6e10194669..5611f0246f 100644 --- a/contracts/evm/script/Deploy.s.sol +++ b/contracts/evm/script/Deploy.s.sol @@ -45,8 +45,8 @@ contract DeployX402Proxies is Script { bytes32 constant EXACT_SALT = 0x0000000000000000000000000000000000000000000000003000000007263b0e; /// @notice Salt for x402UptoPermit2Proxy deterministic deployment - /// @dev Vanity mined for address 0x402015c795ecb48a360bdc6e35a2eaeb313a0002 - bytes32 constant UPTO_SALT = 0x0000000000000000000000000000000000000000000000000800000007e2e4de; + /// @dev Vanity mined for address 0x4020a4f3b7b90cca423b9fabcc0ce57c6c240002 + bytes32 constant UPTO_SALT = 0x000000000000000000000000000000000000000000000000b000000001db633d; /// @notice Expected initCodeHash for x402ExactPermit2Proxy (pre-built, includes CBOR metadata) bytes32 constant EXACT_INIT_CODE_HASH = 0xe774d1d5a07218946ab54efe010b300481478b86861bb17d69c98a57f68a604c; @@ -81,6 +81,21 @@ contract DeployX402Proxies is Script { console2.log(""); } + /// @notice Deploys only x402UptoPermit2Proxy. + function runUpto() public { + address permit2 = vm.envOr("PERMIT2_ADDRESS", CANONICAL_PERMIT2); + + if (block.chainid != 31_337 && block.chainid != 1337) { + require(permit2.code.length > 0, "Permit2 not found on this network"); + console2.log("Permit2 verified"); + + require(CREATE2_DEPLOYER.code.length > 0, "CREATE2 deployer not found on this network"); + console2.log("CREATE2 deployer verified"); + } + + _deployUpto(permit2); + } + function _deployExact( address permit2 ) internal { diff --git a/contracts/evm/src/x402BasePermit2Proxy.sol b/contracts/evm/src/x402BasePermit2Proxy.sol index 1cc5bffcb3..46c6c8863a 100644 --- a/contracts/evm/src/x402BasePermit2Proxy.sol +++ b/contracts/evm/src/x402BasePermit2Proxy.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {ISignatureTransfer} from "./interfaces/ISignatureTransfer.sol"; @@ -22,11 +22,9 @@ import {ISignatureTransfer} from "./interfaces/ISignatureTransfer.sol"; * every chain keeps the initCode identical, preserving a uniform CREATE2 * address for these proxies across all chains. * - * Uses {ReentrancyGuardTransient} (EIP-1153); deploy only on chains with transient storage support. - * * @author x402 Protocol */ -abstract contract x402BasePermit2Proxy is ReentrancyGuardTransient { +abstract contract x402BasePermit2Proxy is ReentrancyGuard { /// @notice The Permit2 contract address (set once at construction, immutable) ISignatureTransfer public immutable PERMIT2; diff --git a/contracts/evm/test/Deploy.t.sol b/contracts/evm/test/Deploy.t.sol new file mode 100644 index 0000000000..bbf988c727 --- /dev/null +++ b/contracts/evm/test/Deploy.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {DeployX402Proxies} from "../script/Deploy.s.sol"; +import {x402ExactPermit2Proxy} from "../src/x402ExactPermit2Proxy.sol"; +import {x402UptoPermit2Proxy} from "../src/x402UptoPermit2Proxy.sol"; + +/// @title DeployX402ProxiesTest +/// @notice Regression tests for the CREATE2 deployment path (non-local chain IDs), where +/// Deploy.s.sol must reproduce the documented canonical addresses exactly. A prior +/// change silently regressed UPTO_SALT to a stale value that no longer matched the +/// live, SDK-referenced x402UptoPermit2Proxy deployment; these tests pin both +/// canonical addresses so a future salt/initCode regression fails loudly here +/// instead of silently deploying an orphaned contract on a new chain. +contract DeployX402ProxiesTest is Test { + address constant CANONICAL_PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + address constant EXPECTED_EXACT_ADDRESS = 0x402085c248EeA27D92E8b30b2C58ed07f9E20001; + address constant EXPECTED_UPTO_ADDRESS = 0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002; + + /// @dev Bytecode of Arachnid's deterministic CREATE2 deployer, same on every EVM chain. + bytes constant CREATE2_DEPLOYER_CODE = + hex"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3"; + + function _simulateRealChain() internal { + vm.chainId(8453); // any non-anvil chain ID forces the CREATE2 (non-local) deployment path + vm.etch(CREATE2_DEPLOYER, CREATE2_DEPLOYER_CODE); + vm.etch(CANONICAL_PERMIT2, hex"60006000fd"); // just needs non-empty code for the prereq check + } + + function test_runDeploysToCanonicalAddresses() public { + _simulateRealChain(); + + DeployX402Proxies deployer = new DeployX402Proxies(); + deployer.run(); + + assertGt(EXPECTED_EXACT_ADDRESS.code.length, 0, "no code at canonical Exact address"); + assertGt(EXPECTED_UPTO_ADDRESS.code.length, 0, "no code at canonical Upto address"); + + assertEq( + address(x402ExactPermit2Proxy(EXPECTED_EXACT_ADDRESS).PERMIT2()), + CANONICAL_PERMIT2, + "Exact PERMIT2 mismatch" + ); + assertEq( + address(x402UptoPermit2Proxy(EXPECTED_UPTO_ADDRESS).PERMIT2()), CANONICAL_PERMIT2, "Upto PERMIT2 mismatch" + ); + } + + function test_runIsIdempotent() public { + _simulateRealChain(); + + DeployX402Proxies deployer = new DeployX402Proxies(); + deployer.run(); + deployer.run(); // must detect existing deployments and skip, not attempt to redeploy + + assertGt(EXPECTED_EXACT_ADDRESS.code.length, 0); + assertGt(EXPECTED_UPTO_ADDRESS.code.length, 0); + } + + function test_runUptoDeploysOnlyUpto() public { + DeployX402Proxies deployer = new DeployX402Proxies(); + uint64 nonceBefore = vm.getNonce(DEFAULT_SENDER); + address expectedAddress = vm.computeCreateAddress(DEFAULT_SENDER, nonceBefore); + + deployer.runUpto(); + + assertEq(vm.getNonce(DEFAULT_SENDER), nonceBefore + 1); + assertGt(expectedAddress.code.length, 0); + x402UptoPermit2Proxy referenceProxy = new x402UptoPermit2Proxy(CANONICAL_PERMIT2); + assertEq(expectedAddress.codehash, address(referenceProxy).codehash); + assertEq(address(x402UptoPermit2Proxy(expectedAddress).PERMIT2()), CANONICAL_PERMIT2); + } + + function test_runUptoDeploysToCanonicalAddress() public { + _simulateRealChain(); + + DeployX402Proxies deployer = new DeployX402Proxies(); + deployer.runUpto(); + + assertGt(EXPECTED_UPTO_ADDRESS.code.length, 0, "no code at canonical Upto address"); + assertEq(EXPECTED_EXACT_ADDRESS.code.length, 0, "Exact should not be deployed"); + assertEq( + address(x402UptoPermit2Proxy(EXPECTED_UPTO_ADDRESS).PERMIT2()), CANONICAL_PERMIT2, "Upto PERMIT2 mismatch" + ); + } +} diff --git a/contracts/evm/vanity-miner/src/main.rs b/contracts/evm/vanity-miner/src/main.rs index ddfbf5713d..54f538d169 100644 --- a/contracts/evm/vanity-miner/src/main.rs +++ b/contracts/evm/vanity-miner/src/main.rs @@ -39,7 +39,7 @@ const EXACT_INIT_CODE_HASH: [u8; 32] = hex_literal::hex!("e774d1d5a07218946ab54efe010b300481478b86861bb17d69c98a57f68a604c"); // x402UptoPermit2Proxy (deterministic build, no CBOR metadata) const UPTO_INIT_CODE_HASH: [u8; 32] = - hex_literal::hex!("01575bfc9cacbf6463db62ee8867594b1657139c8493a712ef6bcefa848a20b7"); + hex_literal::hex!("74f7a29cbc3c55f87cdef7f7c551643189e8bb62eed9de67753aebc402b83797"); // x402BatchSettlement — keccak256(type(x402BatchSettlement).creationCode) after `forge build` const BATCH_INIT_CODE_HASH: [u8; 32] = hex_literal::hex!("ed07c0a1aeb6bd4b8e28932959d66e9f2c1a2f6ebb1fd3dc5f3fbcf8135850f6"); diff --git a/docs/core-concepts/facilitator.md b/docs/core-concepts/facilitator.md index ea54c5db61..87ea7ef3d7 100644 --- a/docs/core-concepts/facilitator.md +++ b/docs/core-concepts/facilitator.md @@ -63,11 +63,17 @@ Multiple facilitators are live in production, supporting various networks includ 11. `Facilitator server` returns a `Payment Execution Response` to the resource server. 12. `Resource server` returns a response to the `Client` with a `PAYMENT-RESPONSE` header containing the `Settlement Response` as Base64-encoded JSON. On success, this is a `200 OK` with the requested resource. On failure, this is a `402 Payment Required` with error details. -### Settlement Pending (EVM) +### Settlement pending and auto-recovery -When an EVM settlement transaction is broadcast but its confirmation cannot be established — for example, due to an RPC error or a timeout while waiting for the receipt — the facilitator returns `settlement_pending` instead of a terminal failure. This is a **non-terminal** error: the transaction may still confirm on chain. The `SettlementResponse` carries the broadcast transaction hash in `transaction` and the network in `network`, so callers can reconcile on chain before deciding whether to retry. +When a settlement transaction is broadcast but its confirmation cannot be established — for example, due to an RPC error or a timeout while waiting for the receipt — the facilitator returns `settlement_pending` instead of a terminal failure. This is a **non-terminal** error: the transaction may still confirm on chain. The `SettlementResponse` carries the broadcast transaction hash in `transaction` and the network in `network`, so callers can reconcile on chain before deciding whether to retry. -This applies to the `exact`, `upto`, and `batch-settlement` EVM schemes (v2 only). +This applies to the `exact`, `upto`, and `batch-settlement` schemes on both EVM and SVM (v2 only). + +#### Automatic retry and reconciliation + +The resource server automatically retries settlement exactly once when it receives a `settlement_pending` response. On the retry, the facilitator mechanism checks a `PendingSettlementStore` for the broadcast transaction hash keyed to the payment payload. When a match is found, the mechanism reconciles against the already-broadcast transaction instead of verifying and broadcasting a second one. This prevents double-spend and avoids redundant on-chain submissions. + +The `PendingSettlementStore` is an interface, not a concrete type, so multi-instance facilitators (running several replicas with no session affinity) can supply a shared, network-backed implementation such as Redis instead of the default in-memory store. The in-memory default only works when the retry lands on the same process. **Facilitators deployed behind a platform request deadline** (serverless functions, gateway timeouts) should bound the receipt wait below that deadline. If the process is killed mid-wait, the caller receives a 5xx with no transaction hash instead of `settlement_pending` with a hash to reconcile against. diff --git a/docs/dev-tools/facilitators.md b/docs/dev-tools/facilitators.md index 1ff59c7b21..63d836f3f7 100644 --- a/docs/dev-tools/facilitators.md +++ b/docs/dev-tools/facilitators.md @@ -25,6 +25,7 @@ If you are evaluating a mainnet EVM route, decide on your production facilitator | [Corbits](https://corbits.dev) | Production-grade multi-network, multi-token facilitator supporting EVM and Solana | | [Dexter](https://dexter.cash/facilitator) | Free public x402 facilitator across Solana and EVM chains with no fees and no account required | | [Fireblocks Facilitator](https://developers.fireblocks.com/docs/x402-facilitator-overview) | Open-source and hosted facilitator with Fireblocks vault settlement; private keys never leave Fireblocks | +| [FTP Canton Facilitator](https://www.ftptech.xyz/x402) | Production x402 facilitator for Canton Network mainnet, supporting Canton Coin and CIP-56 tokens | | [HPP Facilitator](https://docs.hpp.io/x402/facilitator) | Gasless, public x402 facilitator for HPP Mainnet and Sepolia | | [Meridian](https://mrdn.finance) | Multi-chain facilitator with developer-first features | | [Mogami Facilitator](https://facilitator.mogami.tech) | Free, developer-focused facilitator for Base with optional self-hosted Docker deployment | diff --git a/docs/extensions/bazaar.mdx b/docs/extensions/bazaar.mdx index 977ce34987..70057cd342 100644 --- a/docs/extensions/bazaar.mdx +++ b/docs/extensions/bazaar.mdx @@ -463,7 +463,7 @@ The Bazaar extension supports parameterized routes (e.g., `/users/[userId]`, `/w This means `/users/123`, `/users/456`, and `/users/789` all map to the same catalog entry: `/users/:userId`. -**Note:** Wildcard segments (`*`) are automatically converted to named parameters (`:var1`, `:var2`, etc.) for catalog normalization. +**Note:** Wildcard segments (`*`) within a path (e.g. `/api/*/data`) are automatically converted to named parameters (`:var1`, `:var2`, etc.) for catalog normalization. However, bare wildcard patterns (e.g. `"*"` with no literal path segments) do **not** produce a `routeTemplate` — use a keyed path pattern in `withX402` (e.g. `{ "/api/weather": config }`) so the resource is indexed with the correct URL. #### Service Metadata diff --git a/docs/extensions/builder-code.mdx b/docs/extensions/builder-code.mdx index fd0ec513f4..3235523ef6 100644 --- a/docs/extensions/builder-code.mdx +++ b/docs/extensions/builder-code.mdx @@ -392,7 +392,9 @@ client.registerExtension(new BuilderCodeClientExtension(["bc_mcp_server", "bc_my Accepts a single string or an array of strings. Attaches the client's `s` code(s) to every payment payload when registered. When the server declared `builder-code`, the core client merge also preserves the server's `a` code, concatenating any server-declared `s` with the client's (client entries first). Multiple codes are useful for layered clients (e.g. an MCP server acting as middleware) that need to attribute multiple participants. Throws if more than `MAX_CLIENT_SERVICE_CODES` codes are given. -`s` is split into dedicated, non-overlapping reservations: up to `MAX_CLIENT_SERVICE_CODES` (**5**) for the client, `MAX_SERVER_SERVICE_CODES` (**5**) for the server, and `MAX_FACILITATOR_SERVICE_CODES` (**1**) for the facilitator's own code — a total of `MAX_SERVICE_CODES` (**11**). `declareBuilderCodeExtension`, `BuilderCodeClientExtension`, and the facilitator's `serviceCode` config each reject more entries than their own reservation, so no compliant combination can crowd out another party's codes. The resource server also rejects a client echo whose combined `s` exceeds the client+server budget (**10**) before settlement, and facilitators additionally truncate to that same budget as a defensive backstop against a malformed payload sent directly to a facilitator. +`s` is split into dedicated, non-overlapping reservations: up to `MAX_CLIENT_SERVICE_CODES` (**5**) for the client, `MAX_SERVER_SERVICE_CODES` (**5**) for the server, and `MAX_FACILITATOR_SERVICE_CODES` (**1**) for the facilitator's own code — a total of `MAX_SERVICE_CODES` (**11**). `declareBuilderCodeExtension`, `BuilderCodeClientExtension`, and the facilitator's `serviceCode` config each reject more entries than their own reservation, so no compliant combination can crowd out another party's codes. The resource server also rejects a client echo whose combined `s` exceeds the client+server budget (**10**) before settlement, and facilitators additionally truncate to that same budget as a defensive backstop against a malformed payload sent directly to a facilitator. + +`a` is a server-owned field: on v2 payloads, the resource server rejects the payment (`extension_echo_mismatch`) if the client echoes an `a` value that doesn't exactly match the server's advertised value, or if the client sets `a` when the server did not declare `builder-code` at all. Clients must only echo `a` when the server declared it, and must use the exact value from the `PaymentRequired` response. ### Facilitator @@ -409,7 +411,7 @@ facilitator.registerExtension( ); ``` -Reads `a` and `s` from the payment payload at settlement time, adds `w`, appends its own `serviceCode` to `s` (deduped), and encodes the ERC-8021 CBOR suffix. +Reads `a` and `s` from the payment payload at settlement time, adds `w`, appends its own `serviceCode` to `s` (deduped), and encodes the ERC-8021 CBOR suffix. On v1 payloads, `a` is omitted from the suffix (the resource-server echo gate that validates `a` does not run on v1). ### Utilities diff --git a/docs/getting-started/quickstart-for-sellers.mdx b/docs/getting-started/quickstart-for-sellers.mdx index b8822ae9b8..e5e99c3bef 100644 --- a/docs/getting-started/quickstart-for-sellers.mdx +++ b/docs/getting-started/quickstart-for-sellers.mdx @@ -242,26 +242,30 @@ Integrate the payment middleware into your application. You will need to provide export const GET = withX402( handler, { - accepts: [ - { - scheme: "exact", - price: "$0.001", - network: "eip155:84532", // Base Sepolia - payTo: evmAddress, - }, - { - scheme: "exact", - price: "$0.001", - network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Solana Devnet - payTo: svmAddress, - }, - ], - description: "Access to weather API", - mimeType: "application/json", + "/api/weather": { + accepts: [ + { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", // Base Sepolia + payTo: evmAddress, + }, + { + scheme: "exact", + price: "$0.001", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Solana Devnet + payTo: svmAddress, + }, + ], + description: "Access to weather API", + mimeType: "application/json", + }, }, server, ); ``` + + The second argument to `withX402` is a `RoutesConfig`. Key the config by the route's path pattern (e.g. `{ "/api/weather": config }`) so that Bazaar discovery indexes the resource with the correct path. The pattern must match the request path exactly as served (including any `basePath`). If the pattern does not match, the handler runs without payment protection and a warning is logged once. Full example in the repo [here](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/hono). diff --git a/docs/schemes/exact.mdx b/docs/schemes/exact.mdx index 80fb20c567..13359d0353 100644 --- a/docs/schemes/exact.mdx +++ b/docs/schemes/exact.mdx @@ -129,6 +129,100 @@ Use `exact` when the final charge is known before the response is generated, suc +### Upfront payment flow + +By default, `exact` uses the `authorization` flow: the payment is verified before the resource handler runs and settled after. When your resource needs on-chain finality before execution — for example, a long-running handler on Solana where the signed transaction's blockhash may expire before the handler finishes — you can opt in to the `upfront` flow instead. + +With `upfront`, settlement happens **before** the handler runs. The facilitator's `/settle` endpoint both validates and commits the payment; `/verify` is not called. The client signs the same payload as in `authorization`; only the server-side ordering changes. + +To opt in, set `paymentFlow: "upfront"` in the route's `extra` config: + + + + ```typescript + app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: [ + { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + extra: { paymentFlow: "upfront" }, + }, + { + scheme: "exact", + price: "$0.001", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: svmAddress, + extra: { paymentFlow: "upfront" }, + }, + ], + }, + }, + resourceServer, + ), + ); + ``` + + + ```go + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + { + Scheme: "exact", + Price: "$0.001", + Network: x402.Network("eip155:84532"), + PayTo: evmAddress, + Extra: map[string]interface{}{ + "paymentFlow": "upfront", + }, + }, + { + Scheme: "exact", + Price: "$0.001", + Network: x402.Network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + PayTo: svmAddress, + Extra: map[string]interface{}{ + "paymentFlow": "upfront", + }, + }, + }, + }, + } + ``` + + + ```python + routes = { + "GET /weather": RouteConfig( + accepts=[ + PaymentOption( + scheme="exact", + price="$0.001", + network="eip155:84532", + pay_to=evm_address, + extra={"paymentFlow": "upfront"}, + ), + PaymentOption( + scheme="exact", + price="$0.001", + network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + pay_to=svm_address, + extra={"paymentFlow": "upfront"}, + ), + ], + ), + } + ``` + + + +The `authorization` flow remains the default. Clients prefer `authorization` when both flows are offered; the server signals the resolved flow to clients via `extra.paymentFlow` in the 402 response. See [Payment flows](/schemes/overview#payment-flows) for a comparison of all flows. + ### Client Setup @@ -286,37 +380,77 @@ The `exact` scheme has network specifications for EVM, SVM, AVM, Stellar, Aptos, By default, the SVM facilitator only accepts transactions from standard (EOA) wallets using static instruction-layout validation. To also accept payments from Solana smart wallets (Squads, Swig, SPL Governance, etc.), enable simulation-based verification when constructing `ExactSvmScheme` on your facilitator: -```typescript -import { ExactSvmScheme } from "@x402/svm/exact/facilitator"; -import { toFacilitatorSvmSigner } from "@x402/svm"; + + + ```typescript + import { ExactSvmScheme } from "@x402/svm/exact/facilitator"; + import { toFacilitatorSvmSigner } from "@x402/svm"; -const svmSigner = toFacilitatorSvmSigner(svmAccount); + const svmSigner = toFacilitatorSvmSigner(svmAccount); -facilitator.register( - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - new ExactSvmScheme(svmSigner, undefined, { - enableSmartWalletVerification: true, - }), -); -``` + facilitator.register( + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + new ExactSvmScheme(svmSigner, undefined, { + enableSmartWalletVerification: true, + }), + ); + ``` + + + ```go + import ( + svmmech "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + svm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/facilitator" + ) + + settlementCache := svmmech.NewSettlementCache() -When `enableSmartWalletVerification` is enabled, transactions that fail static validation (because they contain smart wallet program instructions) are re-verified by simulating the transaction and inspecting CPI inner instructions for a valid `TransferChecked`. Only programs in the built-in allowlist (Squads Multisig v4, Squads Smart Account, Swig, SPL Governance, Metaplex Core, Lighthouse) can reach this path. + facilitator.Register( + []x402.Network{"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"}, + svm.NewExactSvmScheme(svmSigner, &svm.Config{ + SettlementCache: settlementCache, + EnableSmartWalletVerification: true, + }), + ) + ``` + + + +When `EnableSmartWalletVerification` is enabled, transactions that fail static validation (because they contain smart wallet program instructions) are re-verified by simulating the transaction and inspecting CPI inner instructions for a valid `TransferChecked`. Only programs in the built-in allowlist (Squads Multisig v4, Squads Smart Account, Swig, SPL Governance, Metaplex Core, Lighthouse) can reach this path. -`toFacilitatorSvmSigner()` is required when enabling smart wallet verification — it provides the `simulateTransactionWithInnerInstructions`, `getConfirmedTransactionInnerInstructions`, `getTokenAccountBalance`, and `fetchAddressLookupTables` methods needed for simulation-based verification. +**TypeScript**: `toFacilitatorSvmSigner()` is required when enabling smart wallet verification — it provides the `simulateTransactionWithInnerInstructions`, `getConfirmedTransactionInnerInstructions`, `getTokenAccountBalance`, and `fetchAddressLookupTables` methods needed for simulation-based verification. + +**Go**: Your signer must implement `SmartWalletRPCCapabilities` (i.e. `SimulateTransactionWithInnerInstructions`, `GetConfirmedTransactionInnerInstructions`, `GetTokenAccountBalance`, and `FetchAddressLookupTables`) — `NewExactSvmScheme` panics at startup if `EnableSmartWalletVerification` is true and the signer does not satisfy this interface. Additional options: -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enableSmartWalletVerification` | `boolean` | `false` | Enable simulation-based verification for smart wallet transactions. | -| `smartWalletMaxComputeUnits` | `number` | `400000` | Maximum compute units allowed for smart wallet transactions. | -| `smartWalletMaxPriorityFeeMicroLamports` | `number` | `50000` | Maximum priority fee in microlamports for smart wallet transactions. | -| `smartWalletAllowedPrograms` | `string[]` | Built-in list | Allowed smart wallet program addresses. Only transactions invoking a program in this list reach simulation-based verification. | -| `maxPriorityFeeMicroLamports` | `number` | `5000000` | Maximum compute unit price in microlamports accepted on the static path. The facilitator pays the transaction fee, so this bounds the priority fee a payer can make it pay. | -| `maxComputeUnits` | `number` | No limit | Maximum compute unit limit accepted on the static path. An SPL transfer with a memo uses ~20k CU; a low ceiling still leaves ample headroom for wallet-injected instructions. | -| `maxRequiredSignatures` | `number` | No limit | Maximum number of required signatures. Every signature adds 5,000 lamports of base fee paid by the facilitator. A typical x402 payment needs two (payer + fee payer). | + + + | Option | Type | Default | Description | + |--------|------|---------|-------------| + | `enableSmartWalletVerification` | `boolean` | `false` | Enable simulation-based verification for smart wallet transactions. | + | `smartWalletMaxComputeUnits` | `number` | `400000` | Maximum compute units allowed for smart wallet transactions. | + | `smartWalletMaxPriorityFeeMicroLamports` | `number` | `50000` | Maximum priority fee in microlamports for smart wallet transactions. | + | `smartWalletAllowedPrograms` | `string[]` | Built-in list | Allowed smart wallet program addresses. Only transactions invoking a program in this list reach simulation-based verification. | + | `maxPriorityFeeMicroLamports` | `number` | `5000000` | Maximum compute unit price in microlamports accepted on the static path. The facilitator pays the transaction fee, so this bounds the priority fee a payer can make it pay. | + | `maxComputeUnits` | `number` | No limit | Maximum compute unit limit accepted on the static path. An SPL transfer with a memo uses ~20k CU; a low ceiling still leaves ample headroom for wallet-injected instructions. | + | `maxRequiredSignatures` | `number` | No limit | Maximum number of required signatures. Every signature adds 5,000 lamports of base fee paid by the facilitator. A typical x402 payment needs two (payer + fee payer). | + + + | Field | Type | Default | Description | + |-------|------|---------|-------------| + | `EnableSmartWalletVerification` | `bool` | `false` | Enable simulation-based verification for smart wallet transactions. | + | `SmartWalletMaxComputeUnits` | `*uint32` | `400000` | Maximum compute units allowed for smart wallet transactions. | + | `SmartWalletMaxPriorityFeeMicroLamports` | `*uint64` | `50000` | Maximum priority fee in microlamports for smart wallet transactions. | + | `SmartWalletAllowedPrograms` | `[]string` | Built-in list | Allowed smart wallet program addresses. Only transactions invoking a program in this list reach simulation-based verification. | + | `MaxPriorityFeeMicroLamports` | `*uint64` | `5000000` | Maximum compute unit price in microlamports accepted on the static path. | + | `MaxComputeUnits` | `*uint32` | No limit | Maximum compute unit limit accepted on the static path. | + | `MaxRequiredSignatures` | `*uint8` | No limit | Maximum number of required signatures. Every signature adds 5,000 lamports of base fee paid by the facilitator. | + | `SettlementCache` | `*SettlementCache` | New cache | Shared settlement cache for duplicate detection. Pass the same instance to V1 and V2 SVM schemes. | + + ### EVM Transfer Methods @@ -332,10 +466,13 @@ Permit2 may require a one-time approval. The gas sponsoring extensions can let t ### Examples * [TypeScript server example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/express) +* [TypeScript upfront server example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/upfront) * [TypeScript client example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients/fetch) * [Go server example](https://github.com/x402-foundation/x402/tree/main/examples/go/servers/nethttp) +* [Go upfront server example](https://github.com/x402-foundation/x402/tree/main/examples/go/servers/upfront) * [Go client example](https://github.com/x402-foundation/x402/tree/main/examples/go/clients/http) * [Python server example](https://github.com/x402-foundation/x402/tree/main/examples/python/servers/fastapi) +* [Python upfront server example](https://github.com/x402-foundation/x402/tree/main/examples/python/servers/upfront) * [Python client example](https://github.com/x402-foundation/x402/tree/main/examples/python/clients/requests) ### Specs diff --git a/docs/schemes/overview.mdx b/docs/schemes/overview.mdx index 43f3ad0112..28f7a639f7 100644 --- a/docs/schemes/overview.mdx +++ b/docs/schemes/overview.mdx @@ -60,7 +60,7 @@ Every scheme declares which payment flows it supports per asset transfer method. The server resolves the flow from the scheme's `paymentFlows` table using the `assetTransferMethod` in the payment requirements. When the resolved flow is not `authorization`, the server signals it to clients via `extra.paymentFlow` in the 402 response. -All current scheme implementations declare `authorization` only. +The `exact` scheme supports both `authorization` (default) and `upfront` flows on all networks. Servers opt in per route by setting `extra.paymentFlow: "upfront"` in the route's `accepts` config. The `upto` and `batch-settlement` schemes support `authorization` only. ### Specs diff --git a/docs/schemes/upto.mdx b/docs/schemes/upto.mdx index 5f5559be96..c822b8897e 100644 --- a/docs/schemes/upto.mdx +++ b/docs/schemes/upto.mdx @@ -322,9 +322,12 @@ For custom facilitator implementations, use `UptoSvmScheme` from the facilitator import { toFacilitatorSvmSigner } from "@x402/svm"; import { UptoSvmScheme } from "@x402/svm/upto/facilitator"; - const svmSigner = toFacilitatorSvmSigner(keypair); + // Pass the RPC URL to the signer, not to UptoSvmScheme + const svmSigner = toFacilitatorSvmSigner( + keypair, + process.env.SVM_RPC_URL ? { defaultRpcUrl: process.env.SVM_RPC_URL } : undefined, + ); const svmUptoScheme = new UptoSvmScheme(svmSigner, { - rpcUrl: process.env.SVM_RPC_URL, maxChannelLifetimeSecs: 3600, }); @@ -343,9 +346,9 @@ For custom facilitator implementations, use `UptoSvmScheme` from the facilitator uptosvm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/upto/facilitator" ) + // Pass the RPC URL to the signer, not to uptosvm.Config maxChannelLifetimeSecs := 3600 scheme := uptosvm.NewUptoSvmScheme(svmSigner, &uptosvm.Config{ - RPCURL: os.Getenv("SVM_RPC_URL"), MaxChannelLifetimeSecs: &maxChannelLifetimeSecs, }) facilitator.Register([]x402.Network{network}, scheme) diff --git a/docs/sdk-features.md b/docs/sdk-features.md index 7b21ba657a..d31fccd068 100644 --- a/docs/sdk-features.md +++ b/docs/sdk-features.md @@ -59,6 +59,8 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | upto | svm | - | ✅ | ✅ | ❌ | | batch-settlement | evm | `eip3009` | ✅ | ✅ | ✅ | | batch-settlement | evm | `permit2` | ✅ | ✅ | ✅ | +| auth-capture | evm | `eip3009` | ✅ (client) | ✅ (client) | ❌ | +| auth-capture | evm | `permit2` | ✅ (client) | ✅ (client) | ❌ | ## Extensions diff --git a/e2e/README.md b/e2e/README.md index 5a84aa9d56..0c08892899 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -50,7 +50,7 @@ Four edits, no catalog type to touch: 3. **Client** — register the scheme in `clients//` (e.g. [`clients/typescript/client.ts`](clients/typescript/client.ts) / [`clients/python/client.py`](clients/python/client.py) / [`clients/go/client.go`](clients/go/client.go)). 4. **Facilitator** — register the scheme in [`facilitators/typescript`](facilitators/typescript) / [`facilitators/go`](facilitators/go) / [`facilitators/python`](facilitators/python). -Also add `SERVER_*` / `CLIENT_*` / `FACILITATOR_*` secrets to [`.env-local`](.env-local) and the [Environment Variables](#environment-variables) section below. HTTP frameworks, Next, and MCP all pick up routes and scheme registration from the language-root modules — no per-framework CAIP-2 tables. Only custom flows (e.g. svm-smart-wallet) keep a local `endpoints` overlay. +Also add `SERVER_*` / `CLIENT_*` / `FACILITATOR_*` secrets to [`.env-local`](.env-local) and the [Environment Variables](#environment-variables) section below. HTTP frameworks, Next, and MCP all pick up routes and scheme registration from the language-root modules — no per-framework CAIP-2 tables. Custom client surfaces (e.g. svm-smart-wallet) keep a local `test.config.json` overlay for narrowing (`protocolFamilies`, `facilitators`, extra env) — not a separate catalog route. ## Add an HTTP framework @@ -61,13 +61,13 @@ Also add `SERVER_*` / `CLIENT_*` / `FACILITATOR_*` secrets to [`.env-local`](.en ## Custom flows (escape hatches) -These keep local `endpoints` overlays and/or special orchestration — not just a catalog append: +These keep local `test.config.json` overlays and/or special orchestration — not just a catalog append: | Flow | Where it lives | |------|----------------| | Batch-settlement multi-phase | Catalog `routes` entries + orchestration in [`test.ts`](test.ts) + shared scheme registration | | Gas sponsoring / Permit2 coldstart | Route `schemeOptions.coldstart` + declared gas `extensions` + fund/revoke/drain in `test.ts` + facilitator extension registration | -| Swig smart wallet | Overlay [`clients/typescript/http/svm-smart-wallet/test.config.json`](clients/typescript/http/svm-smart-wallet/test.config.json) + [`scripts/swig-setup.ts`](scripts/swig-setup.ts) | +| Swig smart wallet | Client overlay [`clients/typescript/http/svm-smart-wallet/test.config.json`](clients/typescript/http/svm-smart-wallet/test.config.json) (`protocolFamilies`, `facilitators`, Swig env) + [`scripts/swig-setup.ts`](scripts/swig-setup.ts); uses catalog route `/exact/svm` | | Cardano one-shot Masumi offer | Catalog `routes."/exact/cardano/masumi".schemeOptions.masumi` + per-request issuance in [`servers/typescript/config.ts`](servers/typescript/config.ts). Cardano is excluded from MCP (`excludeNetworks` overlay on the MCP server): a Masumi 402 is a one-shot seller-signed quote that must be issued per request, which needs the HTTP dynamic `payTo`/`price` path; the MCP server builds its requirements once at startup via `buildPaymentRequirements`, which does not resolve dynamic values. | | Legacy (v1) | `legacy/` trees only — separate configs; do not extend the mechanisms catalog for v1 | @@ -114,6 +114,15 @@ cd facilitators/go go mod tidy && go build -o go . ``` +### Wallet status + +Print facilitator / client / server addresses plus facilitator native and client payment-token balances for every family whose catalog-required env keys are set. + +```bash +pnpm wallet:status +pnpm wallet:status --mainnet +``` + ## Usage ### Interactive Test Mode @@ -129,6 +138,8 @@ Launches an interactive CLI where you can select: - **Extensions** - Additional features like Bazaar discovery - **Protocols** - EVM, SVM, AVM, Aptos, Concordium, Hedera, NEAR, Stellar, and/or TVM networks - **Payment schemes** (when multiple apply) - `exact`, `upto`, or `batch-settlement` +- **Payment flows** (when multiple apply) - `authorization`, `upfront`, or `escrow` +- **Asset transfer methods** (when multiple apply) - `eip3009`, `permit2`, `sequence`, or `ticketSequence` Every valid combination of your selections will be tested. For example, selecting 2 facilitators, 3 servers, and 2 clients will generate and run all compatible test scenarios. @@ -261,6 +272,14 @@ TVM_TONAPI_API_KEY= \ pnpm test --testnet --families=tvm --facilitators=python --clients=python/http/httpx,python/http/requests --servers=python/http/fastapi,python/http/flask --min -v ``` +Catalog dimensions can also be filtered from the CLI (`pnpm test --help` for the full list): + +```bash +pnpm test --testnet --min --families=evm --sdk=ts --paymentflow=upfront --assetTransferMethod=eip3009 +``` + +`--sdk` keeps scenarios whose client, server, and facilitator are that language (`ts` / `typescript`, `py` / `python`, `go`). `--paymentflow` and `--assetTransferMethod` match the catalog route fields (omitted `paymentFlow` is `authorization`). + Optional environment variables (batch-settlement scheme): ```bash diff --git a/e2e/clients/python/http/httpx/uv.lock b/e2e/clients/python/http/httpx/uv.lock index 152e83565a..33c2a181a3 100644 --- a/e2e/clients/python/http/httpx/uv.lock +++ b/e2e/clients/python/http/httpx/uv.lock @@ -2344,7 +2344,7 @@ wheels = [ [[package]] name = "x402" -version = "2.18.0" +version = "2.21.0" source = { editable = "../../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, diff --git a/e2e/clients/python/http/requests/uv.lock b/e2e/clients/python/http/requests/uv.lock index 46bf12c9f1..a3d1062279 100644 --- a/e2e/clients/python/http/requests/uv.lock +++ b/e2e/clients/python/http/requests/uv.lock @@ -7,6 +7,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + [[package]] name = "abnf" version = "2.7.0" @@ -2320,54 +2324,27 @@ wheels = [ name = "solana" version = "0.38.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "aiolimiter", marker = "python_full_version < '3.11'" }, - { name = "construct-typing", marker = "python_full_version < '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "aiolimiter" }, + { name = "construct-typing" }, + { name = "httpx2", extra = ["http2"] }, + { name = "pydantic" }, + { name = "solders" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/74/9f0621b79059d1e8c010d63514316fb691d94f0c408a215fb71173c5a896/solana-0.38.0.tar.gz", hash = "sha256:ca2c6441483ce5c0ce57d3538ae35cbe04e9c79e6cfc76942799746bfd9ff2d6", size = 498247, upload-time = "2026-06-22T01:42:08.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/1d/4f1d03735b33f32f5223026c45240c61c0730de598f78f1dcbb129389443/solana-0.38.0-py3-none-any.whl", hash = "sha256:396360b01b7ec463c3c92f25450eaf264202fd629f78054bb935ebb0a0410ff5", size = 73328, upload-time = "2026-06-22T01:42:07.509Z" }, ] -[[package]] -name = "solana" -version = "0.40.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "aiolimiter", marker = "python_full_version >= '3.11'" }, - { name = "construct-typing", marker = "python_full_version >= '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/f1728d9f44c479dbbf9d46814511d1a2b46affb314f72bf5810e7b0facd6/solana-0.40.1.tar.gz", hash = "sha256:75fd05aabba2dfeb4f6810e785e4aded3e119daee9cc8752584f244d82ef2c11", size = 474358, upload-time = "2026-07-17T00:05:08.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/af/64ac2e9bc18d4f75e946af41d605213c82c3fa75525837bbe68f68cc7211/solana-0.40.1-py3-none-any.whl", hash = "sha256:25a093bf2b5dfa439a99390088878feca0694703b8dd2dc6786ed84c72da1c20", size = 48305, upload-time = "2026-07-17T00:05:06.828Z" }, -] - [[package]] name = "solders" version = "0.27.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "jsonalias", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "jsonalias" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } wheels = [ @@ -2381,28 +2358,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, ] -[[package]] -name = "solders" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "jsonalias", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/b5/739f342aecacedf2a55e7baaff10714f1d70b4a4ccf2df4155fbe85a5a38/solders-0.28.0.tar.gz", hash = "sha256:c910d4fcc97bb00ec0146425b73a74e51241865de743be215b6c2fb664040013", size = 174557, upload-time = "2026-07-01T17:29:31.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/35/2ea73d0f6b7701a6747991842f76addb506dcc1cdcd1c310a6543e214d68/solders-0.28.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:07844e22f7a7b8fd698a46f2a09d355d35d9898a1bd86017ba6cb519eb11e4c1", size = 23084694, upload-time = "2026-07-01T17:29:14.522Z" }, - { url = "https://files.pythonhosted.org/packages/57/9e/cfdd9eea38d221fc72844b030ac3bb3e5dc60758aaf023dc77482e76019e/solders-0.28.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015ae6841cea52539521bb76c121df5742a476d1ae3718f1ddcd0d00f015fa59", size = 12056383, upload-time = "2026-07-01T17:29:18.016Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cb/565ac41638d925516cf8898f5fb574f2f9c4110159af5e2da7d4cb163dbd/solders-0.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad8c3f4516cb2c0214763766b10c065b64cdda1efed9440319aceac3cdc2615", size = 12513925, upload-time = "2026-07-01T17:29:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/4f/96/b8a74e55a0c978c776a30decc64266d56525eca89d5460c4965bf1c05735/solders-0.28.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cd3635e478765c279788b5a0875c0be19e72ec5ac37f36659479d02f47287594", size = 12629581, upload-time = "2026-07-01T17:29:23.654Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a7/5a6385537c6ae1071583b3538f1c40a8377d0b814d21505d5ccd535383cb/solders-0.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e21fd158660865f46dd93f0986f0606973f91833c723dc6f42c90e76aeaacf4d", size = 12663803, upload-time = "2026-07-01T17:29:26.594Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/6a6eb6e944c00579555de72a81bfb8191299e05aa2f1fbd28837179e6110/solders-0.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:38eceb69ecb514cb8b5ee9a7a80434f147ab0655094ab2bfc685b31a115acdc4", size = 11152191, upload-time = "2026-07-01T17:29:29.269Z" }, -] - [[package]] name = "toolz" version = "1.1.0" @@ -2558,7 +2513,7 @@ wheels = [ [[package]] name = "x402" -version = "2.19.0" +version = "2.21.0" source = { editable = "../../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -2584,10 +2539,8 @@ requests = [ { name = "requests" }, ] svm = [ - { name = "solana", version = "0.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solana", version = "0.40.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "solana" }, + { name = "solders" }, ] tvm = [ { name = "httpx" }, diff --git a/e2e/clients/python/mcp/uv.lock b/e2e/clients/python/mcp/uv.lock index c29f8337cc..ca028a84d8 100644 --- a/e2e/clients/python/mcp/uv.lock +++ b/e2e/clients/python/mcp/uv.lock @@ -2447,7 +2447,7 @@ wheels = [ [[package]] name = "x402" -version = "2.18.0" +version = "2.21.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, diff --git a/e2e/clients/typescript/client.ts b/e2e/clients/typescript/client.ts index 0cba376944..a2f3993bca 100644 --- a/e2e/clients/typescript/client.ts +++ b/e2e/clients/typescript/client.ts @@ -78,7 +78,7 @@ export type E2EClientContext = { * setup instead of duplicating it. */ schemes: SchemeRegistration[]; - batchSettlementScheme: BatchSettlementEvmScheme; + batchSettlementScheme: BatchSettlementEvmScheme | undefined; batchSettlementPhase: BatchSettlementPhase | undefined; }; @@ -89,50 +89,85 @@ export async function createE2EClient(): Promise { const baseURL = process.env.RESOURCE_SERVER_URL as string; const endpointPath = process.env.ENDPOINT_PATH as string; const url = `${baseURL}${endpointPath}`; - const evmAccount = privateKeyToAccount(process.env.CLIENT_EVM_PRIVATE_KEY as `0x${string}`); - const svmSigner = await createKeyPairSignerFromBytes( - base58.decode(process.env.CLIENT_SVM_PRIVATE_KEY as string), - ); - - const evmNetwork = resolveNetworkCaip2("evm"); - const evmRpcUrl = process.env.EVM_RPC_URL; - const evmChain = evmNetwork === "eip155:8453" ? base : baseSepolia; - - const publicClient = createPublicClient({ - chain: evmChain, - transport: http(evmRpcUrl), - }); - const evmSigner = toClientEvmSigner(evmAccount, publicClient); + const schemes: SchemeRegistration[] = []; + let batchSettlementScheme: BatchSettlementEvmScheme | undefined; - const evmSchemeOptions = process.env.EVM_RPC_URL - ? { rpcUrl: process.env.EVM_RPC_URL } - : undefined; + if (process.env.CLIENT_EVM_PRIVATE_KEY) { + const evmAccount = privateKeyToAccount(process.env.CLIENT_EVM_PRIVATE_KEY as `0x${string}`); - const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = process.env.EVM_RPC_URL - ? { rpcUrl: process.env.EVM_RPC_URL } - : undefined; - const svmSchemeOptions = process.env.SVM_RPC_URL ? { rpcUrl: process.env.SVM_RPC_URL } : undefined; + const evmNetwork = resolveNetworkCaip2("evm"); + const evmRpcUrl = process.env.EVM_RPC_URL; + const evmChain = evmNetwork === "eip155:8453" ? base : baseSepolia; - const ccdPrivateKey = process.env.CLIENT_CCD_PRIVATE_KEY; - const ccdAddress = process.env.CLIENT_CCD_ADDRESS; + const publicClient = createPublicClient({ + chain: evmChain, + transport: http(evmRpcUrl), + }); - // Batch-settlement scheme uses a per-scenario salt (EVM_BATCH_SETTLEMENT_CHANNEL) so - // concurrent e2e runs don't collide on the same on-chain channel id. An optional - // voucher signer (CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY) exercises - // the alt-EOA voucher branch while deposits keep using the main client signer. - const channelSalt = process.env.EVM_BATCH_SETTLEMENT_CHANNEL as `0x${string}` | undefined; - const voucherSignerKey = process.env.CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY as - | `0x${string}` - | undefined; - const voucherSigner = voucherSignerKey - ? toClientEvmSigner(privateKeyToAccount(voucherSignerKey), publicClient) - : undefined; - const batchSettlementOptions = - channelSalt || voucherSigner - ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } + const evmSigner = toClientEvmSigner(evmAccount, publicClient); + + const evmSchemeOptions = evmRpcUrl ? { rpcUrl: evmRpcUrl } : undefined; + + const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = evmRpcUrl + ? { rpcUrl: evmRpcUrl } + : undefined; + + // Batch-settlement scheme uses a per-scenario salt (EVM_BATCH_SETTLEMENT_CHANNEL) so + // concurrent e2e runs don't collide on the same on-chain channel id. An optional + // voucher signer (CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY) exercises + // the alt-EOA voucher branch while deposits keep using the main client signer. + const channelSalt = process.env.EVM_BATCH_SETTLEMENT_CHANNEL as `0x${string}` | undefined; + const voucherSignerKey = process.env.CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY as + | `0x${string}` + | undefined; + const voucherSigner = voucherSignerKey + ? toClientEvmSigner(privateKeyToAccount(voucherSignerKey), publicClient) : undefined; - const batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + const batchSettlementOptions = + channelSalt || voucherSigner + ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } + : undefined; + batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + + schemes.push( + { network: networkCaip2Pattern("evm"), client: new ExactEvmScheme(evmSigner, evmSchemeOptions) }, + { + network: networkCaip2Pattern("evm"), + client: new UptoEvmClientScheme(evmSigner, uptoSchemeOptions), + }, + { network: networkCaip2Pattern("evm"), client: batchSettlementScheme }, + { network: "base-sepolia", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, + { network: "base", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, + ); + } + + if (process.env.CLIENT_SVM_PRIVATE_KEY) { + const svmSigner = await createKeyPairSignerFromBytes( + base58.decode(process.env.CLIENT_SVM_PRIVATE_KEY), + ); + const svmSchemeOptions = process.env.SVM_RPC_URL ? { rpcUrl: process.env.SVM_RPC_URL } : undefined; + + schemes.push( + { + network: networkCaip2Pattern("svm"), + client: new ExactSvmScheme(svmSigner, svmSchemeOptions), + }, + { + network: networkCaip2Pattern("svm"), + client: new UptoSvmScheme(svmSigner, svmSchemeOptions), + }, + { + network: "solana-devnet", + client: new ExactSvmSchemeV1(svmSigner, svmSchemeOptions), + x402Version: 1, + }, + { network: "solana", client: new ExactSvmSchemeV1(svmSigner, svmSchemeOptions), x402Version: 1 }, + ); + } + + const ccdPrivateKey = process.env.CLIENT_CCD_PRIVATE_KEY; + const ccdAddress = process.env.CLIENT_CCD_ADDRESS; let aptosAccount: Account | undefined; if (process.env.CLIENT_APTOS_PRIVATE_KEY) { @@ -192,30 +227,6 @@ export async function createE2EClient(): Promise { ) : undefined; - const schemes: SchemeRegistration[] = [ - { network: networkCaip2Pattern("evm"), client: new ExactEvmScheme(evmSigner, evmSchemeOptions) }, - { - network: networkCaip2Pattern("evm"), - client: new UptoEvmClientScheme(evmSigner, uptoSchemeOptions), - }, - { network: networkCaip2Pattern("evm"), client: batchSettlementScheme }, - { network: "base-sepolia", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, - { network: "base", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, - { - network: networkCaip2Pattern("svm"), - client: new ExactSvmScheme(svmSigner, svmSchemeOptions), - }, - { - network: networkCaip2Pattern("svm"), - client: new UptoSvmScheme(svmSigner, svmSchemeOptions), - }, - { - network: "solana-devnet", - client: new ExactSvmSchemeV1(svmSigner, svmSchemeOptions), - x402Version: 1, - }, - { network: "solana", client: new ExactSvmSchemeV1(svmSigner, svmSchemeOptions), x402Version: 1 }, - ]; if (ccdPrivateKey && ccdAddress) { schemes.push({ network: networkCaip2Pattern("ccd"), @@ -343,7 +354,7 @@ function aggregateBatchResult( export type ClientScenarioDeps = { url: string; batchSettlementPhase: BatchSettlementPhase | undefined; - batchSettlementScheme: BatchSettlementEvmScheme; + batchSettlementScheme: BatchSettlementEvmScheme | undefined; issueRequest: () => Promise; /** * Overrides how the cooperative refund request is sent. Defaults to @@ -395,7 +406,13 @@ async function awaitCardanoWalletSettled(result: RequestResult): Promise { */ export async function runClientScenario(deps: ClientScenarioDeps): Promise { const { url, batchSettlementPhase, batchSettlementScheme, issueRequest } = deps; - const sendRefund = deps.refund ?? (() => batchSettlementScheme.refund(url)); + if (batchSettlementPhase && !batchSettlementScheme) { + throw new Error( + "EVM_BATCH_SETTLEMENT_PHASE is set but no CLIENT_EVM_PRIVATE_KEY was provided to build a " + + "batch-settlement scheme from.", + ); + } + const sendRefund = deps.refund ?? (() => batchSettlementScheme!.refund(url)); if (!batchSettlementPhase) { const result = await issueRequest(); diff --git a/e2e/clients/typescript/http/svm-smart-wallet/README.md b/e2e/clients/typescript/http/svm-smart-wallet/README.md index d9b1239904..85a8176483 100644 --- a/e2e/clients/typescript/http/svm-smart-wallet/README.md +++ b/e2e/clients/typescript/http/svm-smart-wallet/README.md @@ -2,7 +2,9 @@ Exercises x402 SVM **Path 2** (simulation-based smart wallet verification) using a [Swig](https://build.onswig.com/) wallet via [`@swig-wallet/kit`](https://build.onswig.com/reference/typescript/kit). -Path 1 static validation rejects Swig-wrapped transactions (unknown program layout). With `enableSmartWalletVerification: true` on the facilitator, verification falls back to Path 2: simulate the transaction, inspect CPI inner instructions for `TransferChecked`, and match amount/mint/recipient. +Path 1 static validation rejects Swig-wrapped transactions (unknown program layout). With smart wallet verification enabled on the facilitator, verification falls back to Path 2: simulate the transaction, inspect CPI inner instructions for `TransferChecked`, and match amount/mint/recipient. + +This is a **client variant** like `fetch` or `axios` — it hits the same catalog route `/exact/svm` as other SVM clients. The local [`test.config.json`](test.config.json) overlay narrows it to SVM exact v2, declares Swig env, and lists compatible facilitators (`typescript`, `go` until Python adds Path 2). ## How it works @@ -37,11 +39,9 @@ First run writes `SWIG_ACCOUNT_ADDRESS` to `e2e/.env` automatically. ```bash cd e2e pnpm install -pnpm test --testnet --clients=typescript/http/svm-smart-wallet --servers=typescript/http/express --facilitators=typescript --endpoints=/exact/svm --min +pnpm test --testnet --clients=typescript/http/svm-smart-wallet --servers=typescript/http/express --endpoints=/exact/svm --min ``` -The harness runs `swig-setup` automatically before each svm-smart-wallet endpoint when the Swig USDC balance is below one payment — no manual setup needed for `pnpm test`. - -Use the **TypeScript** facilitator — it must register `ExactSvmScheme` with `enableSmartWalletVerification: true` (configured in `e2e/facilitators/typescript`). +The harness runs `swig-setup` automatically before each svm-smart-wallet scenario when the Swig USDC balance is below one payment — no manual setup needed for `pnpm test`. -Go and Python facilitators do not implement SVM Path 2 yet; tests against those facilitators will fail verification. +TypeScript and Go e2e facilitators register SVM exact with smart wallet verification enabled. Scenario discovery reads `facilitators` from this client's overlay, so Python is excluded until it implements Path 2. diff --git a/e2e/clients/typescript/http/svm-smart-wallet/index.ts b/e2e/clients/typescript/http/svm-smart-wallet/index.ts index 306920f70d..439c4e190c 100644 --- a/e2e/clients/typescript/http/svm-smart-wallet/index.ts +++ b/e2e/clients/typescript/http/svm-smart-wallet/index.ts @@ -48,44 +48,54 @@ const client = new x402Client().register( ); const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const httpClient = new x402HTTPClient(client); -fetchWithPayment(url, { - method: "GET", -}) - .then(async response => { - const data = await response.json(); - const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => - response.headers.get(name), - ); - - if (!paymentResponse) { - console.log( - JSON.stringify({ - success: true, - data, - status_code: response.status, - }), - ); - process.exit(0); - return; - } +try { + const response = await fetchWithPayment(url, { method: "GET" }); + const parsed = await httpClient.processResponse(response); + if (parsed.paymentStatus === "payment_required") { + const header = parsed.header; + const reason = + header && !("success" in header) && header.error ? header.error : "Payment required"; console.log( JSON.stringify({ - success: paymentResponse.success, - data, - status_code: response.status, - payment_response: paymentResponse, + success: false, + error: reason, + data: parsed.body, + status_code: parsed.status, }), ); - process.exit(paymentResponse.success ? 0 : 1); - }) - .catch(error => { + process.exit(1); + } + + if (parsed.paymentStatus === "none") { console.log( JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), + success: true, + data: parsed.body, + status_code: parsed.status, }), ); - process.exit(1); - }); + process.exit(0); + } + + const paymentResponse = parsed.header && "success" in parsed.header ? parsed.header : undefined; + console.log( + JSON.stringify({ + success: parsed.paymentStatus === "settled", + data: parsed.body, + status_code: parsed.status, + payment_response: paymentResponse, + }), + ); + process.exit(parsed.paymentStatus === "settled" ? 0 : 1); +} catch (error) { + console.log( + JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + process.exit(1); +} diff --git a/e2e/clients/typescript/http/svm-smart-wallet/swigScheme.ts b/e2e/clients/typescript/http/svm-smart-wallet/swigScheme.ts index bf49609ce9..4dea620f19 100644 --- a/e2e/clients/typescript/http/svm-smart-wallet/swigScheme.ts +++ b/e2e/clients/typescript/http/svm-smart-wallet/swigScheme.ts @@ -23,12 +23,13 @@ import { type KeyPairSigner, } from "@solana/kit"; import { fetchSwig, getSignInstructions, getSwigWalletAddress } from "@swig-wallet/kit"; +import { findDefaultAsset } from "@x402/svm"; import type { PaymentPayload, PaymentRequirements, SchemeNetworkClient } from "@x402/core/types"; const DEVNET_RPC_URL = "https://api.devnet.solana.com"; const MEMO_PROGRAM_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" as Address; const MAX_MEMO_BYTES = 256; -const SMART_WALLET_COMPUTE_UNIT_LIMIT = 200_000; +const SMART_WALLET_COMPUTE_UNIT_LIMIT = 400_000; const SMART_WALLET_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 10_000n; /** @@ -41,6 +42,7 @@ const SMART_WALLET_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 10_000n; */ export class ExactSwigSvmScheme implements SchemeNetworkClient { readonly scheme = "exact"; + findDefaultAsset = findDefaultAsset; /** * @param authority - Ed25519 signer for the Swig root role diff --git a/e2e/clients/typescript/http/svm-smart-wallet/test.config.json b/e2e/clients/typescript/http/svm-smart-wallet/test.config.json index 9fb3c603a8..b7df4fa63a 100644 --- a/e2e/clients/typescript/http/svm-smart-wallet/test.config.json +++ b/e2e/clients/typescript/http/svm-smart-wallet/test.config.json @@ -1,11 +1,21 @@ { "name": "typescript/http/svm-smart-wallet", "type": "client", - "enabled": false, + "enabled": true, "language": "typescript", - "protocolFamilies": ["svm"], - "x402Versions": [2], - "schemes": ["exact"], + "protocolFamilies": [ + "svm" + ], + "x402Versions": [ + 2 + ], + "schemes": [ + "exact" + ], + "facilitators": [ + "typescript", + "go" + ], "environment": { "required": [ "CLIENT_SVM_PRIVATE_KEY", @@ -18,4 +28,4 @@ "SWIG_ID_BASE58" ] } -} +} \ No newline at end of file diff --git a/e2e/clients/typescript/mcp/index.ts b/e2e/clients/typescript/mcp/index.ts index 7787f0b403..e51e70c3b6 100644 --- a/e2e/clients/typescript/mcp/index.ts +++ b/e2e/clients/typescript/mcp/index.ts @@ -115,7 +115,7 @@ try { batchSettlementPhase, batchSettlementScheme, issueRequest, - refund: () => batchSettlementScheme.refund(toolResourceUrl, { fetch: mcpRefundFetch }), + refund: () => batchSettlementScheme!.refund(toolResourceUrl, { fetch: mcpRefundFetch }), }); } catch (error: unknown) { console.log( diff --git a/e2e/config/mechanisms_aptos.json b/e2e/config/mechanisms_aptos.json index 88748f738b..83cdef3fd4 100644 --- a/e2e/config/mechanisms_aptos.json +++ b/e2e/config/mechanisms_aptos.json @@ -20,6 +20,13 @@ "sdks": ["typescript"], "price": { "usd": "$0.001" }, "extensions": ["bazaar"] + }, + "/exact/aptos/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_avm.json b/e2e/config/mechanisms_avm.json index bcc7d21303..306470d6cd 100644 --- a/e2e/config/mechanisms_avm.json +++ b/e2e/config/mechanisms_avm.json @@ -20,6 +20,13 @@ "sdks": ["typescript"], "price": { "usd": "$0.001" }, "extensions": ["bazaar"] + }, + "/exact/avm/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_ccd.json b/e2e/config/mechanisms_ccd.json index ab35915903..b3c76cefb6 100644 --- a/e2e/config/mechanisms_ccd.json +++ b/e2e/config/mechanisms_ccd.json @@ -27,6 +27,17 @@ "asset": "CCD" }, "extensions": ["bazaar"] + }, + "/exact/ccd/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { + "amount": "1000", + "amountEnv": "CCD_WEATHER_PRICE_MICRO_CCD", + "asset": "CCD" + }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_evm.json b/e2e/config/mechanisms_evm.json index 1247763893..2343eaccd8 100644 --- a/e2e/config/mechanisms_evm.json +++ b/e2e/config/mechanisms_evm.json @@ -1,19 +1,58 @@ { "env": { - "SERVER_EVM_ADDRESS": { "required": true, "roles": ["server"] }, - "CLIENT_EVM_PRIVATE_KEY": { "required": true, "roles": ["client"] }, - "FACILITATOR_EVM_PRIVATE_KEY": { "required": true, "roles": ["facilitator"] }, + "SERVER_EVM_ADDRESS": { + "required": true, + "roles": [ + "server" + ] + }, + "CLIENT_EVM_PRIVATE_KEY": { + "required": true, + "roles": [ + "client" + ] + }, + "FACILITATOR_EVM_PRIVATE_KEY": { + "required": true, + "roles": [ + "facilitator" + ] + }, "SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY": { "required": false, - "roles": ["server"] + "roles": [ + "server" + ] + }, + "EVM_PERMIT2_ASSET": { + "required": false, + "roles": [ + "server" + ] + }, + "EVM_BATCH_SETTLEMENT_CHANNEL": { + "required": false, + "roles": [ + "client" + ] + }, + "EVM_BATCH_SETTLEMENT_PHASE": { + "required": false, + "roles": [ + "client" + ] + }, + "EVM_BATCH_SETTLEMENT_RECOVERY": { + "required": false, + "roles": [ + "client" + ] }, - "EVM_PERMIT2_ASSET": { "required": false, "roles": ["server"] }, - "EVM_BATCH_SETTLEMENT_CHANNEL": { "required": false, "roles": ["client"] }, - "EVM_BATCH_SETTLEMENT_PHASE": { "required": false, "roles": ["client"] }, - "EVM_BATCH_SETTLEMENT_RECOVERY": { "required": false, "roles": ["client"] }, "CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY": { "required": false, - "roles": ["client"] + "roles": [ + "client" + ] } }, "testnet": { @@ -34,122 +73,259 @@ "/batch-settlement/evm/eip3009": { "scheme": "batch-settlement", "assetTransferMethod": "eip3009", - "sdks": ["typescript", "go", "python"], - "price": { "usd": "$0.001" }, - "extensions": ["bazaar"] + "sdks": [ + "typescript", + "go", + "python" + ], + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ] }, "/batch-settlement/evm/permit2": { "scheme": "batch-settlement", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "permit2Direct": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "permit2Direct": true + }, "price": { "amount": "1000", "assetRef": "permit2", "permit2Domain": true }, - "extensions": ["bazaar"] + "extensions": [ + "bazaar" + ] }, "/batch-settlement/evm/permit2-eip2612GasSponsoring": { "scheme": "batch-settlement", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "usd": "$0.001", "declareAssetTransferMethod": true }, - "extensions": ["bazaar", "eip2612GasSponsoring"] + "extensions": [ + "bazaar", + "eip2612GasSponsoring" + ] }, "/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring": { "scheme": "batch-settlement", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "amount": "1000", "assetRef": "permit2" }, - "extensions": ["bazaar", "erc20ApprovalGasSponsoring"] + "extensions": [ + "bazaar", + "erc20ApprovalGasSponsoring" + ] }, "/exact/evm/eip3009": { "scheme": "exact", "assetTransferMethod": "eip3009", - "sdks": ["typescript", "go", "python"], - "price": { "usd": "$0.001" }, - "extensions": ["bazaar"] + "sdks": [ + "typescript", + "go", + "python" + ], + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ] + }, + "/exact/evm/eip3009/upfront": { + "scheme": "exact", + "assetTransferMethod": "eip3009", + "sdks": [ + "typescript", + "go", + "python" + ], + "paymentFlow": "upfront", + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ] }, "/exact/evm/permit2": { "scheme": "exact", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "permit2Direct": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "permit2Direct": true + }, "price": { "amount": "1000", "assetRef": "permit2", "permit2Domain": true }, - "extensions": ["bazaar"] + "extensions": [ + "bazaar" + ] + }, + "/exact/evm/permit2/upfront": { + "scheme": "exact", + "assetTransferMethod": "permit2", + "sdks": [ + "typescript", + "go", + "python" + ], + "paymentFlow": "upfront", + "schemeOptions": { + "permit2Direct": true + }, + "price": { + "amount": "1000", + "assetRef": "permit2", + "permit2Domain": true + }, + "extensions": [ + "bazaar" + ] }, "/exact/evm/permit2-eip2612GasSponsoring": { "scheme": "exact", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "usd": "$0.001", "declareAssetTransferMethod": true }, - "extensions": ["bazaar", "eip2612GasSponsoring"] + "extensions": [ + "bazaar", + "eip2612GasSponsoring" + ] }, "/exact/evm/permit2-erc20ApprovalGasSponsoring": { "scheme": "exact", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "amount": "1000", "assetRef": "permit2" }, - "extensions": ["bazaar", "erc20ApprovalGasSponsoring"] + "extensions": [ + "bazaar", + "erc20ApprovalGasSponsoring" + ] }, "/upto/evm/permit2": { "scheme": "upto", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "permit2Direct": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "permit2Direct": true + }, "price": { "amount": "2000", "assetRef": "permit2", "permit2Domain": true }, - "extensions": ["bazaar"], - "settlementOverride": { "amount": "1000" } + "extensions": [ + "bazaar" + ], + "settlementOverride": { + "amount": "1000" + } }, "/upto/evm/permit2-eip2612GasSponsoring": { "scheme": "upto", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "amount": "2000", "assetRef": "permit2", "permit2Domain": true }, - "extensions": ["bazaar", "eip2612GasSponsoring"], - "settlementOverride": { "amount": "1000" } + "extensions": [ + "bazaar", + "eip2612GasSponsoring" + ], + "settlementOverride": { + "amount": "1000" + } }, "/upto/evm/permit2-erc20ApprovalGasSponsoring": { "scheme": "upto", "assetTransferMethod": "permit2", - "sdks": ["typescript", "go", "python"], - "schemeOptions": { "coldstart": true }, + "sdks": [ + "typescript", + "go", + "python" + ], + "schemeOptions": { + "coldstart": true + }, "price": { "amount": "2000", "assetRef": "permit2" }, - "extensions": ["bazaar", "erc20ApprovalGasSponsoring"], - "settlementOverride": { "amount": "1000" } + "extensions": [ + "bazaar", + "erc20ApprovalGasSponsoring" + ], + "settlementOverride": { + "amount": "1000" + } } } -} +} \ No newline at end of file diff --git a/e2e/config/mechanisms_hedera.json b/e2e/config/mechanisms_hedera.json index c67daf3bd1..8d54ce60d1 100644 --- a/e2e/config/mechanisms_hedera.json +++ b/e2e/config/mechanisms_hedera.json @@ -29,6 +29,18 @@ "assetEnv": "HEDERA_ASSET" }, "extensions": ["bazaar"] + }, + "/exact/hedera/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { + "amount": "100000", + "amountEnv": "HEDERA_AMOUNT", + "asset": "0.0.0", + "assetEnv": "HEDERA_ASSET" + }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_keeta.json b/e2e/config/mechanisms_keeta.json index 44731b6b13..423916d817 100644 --- a/e2e/config/mechanisms_keeta.json +++ b/e2e/config/mechanisms_keeta.json @@ -20,6 +20,13 @@ "sdks": ["typescript"], "price": { "usd": "$0.001" }, "extensions": ["bazaar"] + }, + "/exact/keeta/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_near.json b/e2e/config/mechanisms_near.json index 94cdbf453e..894ff49bb3 100644 --- a/e2e/config/mechanisms_near.json +++ b/e2e/config/mechanisms_near.json @@ -29,6 +29,18 @@ "assetEnv": "SERVER_NEAR_ASSET" }, "extensions": ["bazaar"] + }, + "/exact/near/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { + "amount": "1000000000000000000000", + "amountEnv": "SERVER_NEAR_AMOUNT", + "asset": "wrap.testnet", + "assetEnv": "SERVER_NEAR_ASSET" + }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_stellar.json b/e2e/config/mechanisms_stellar.json index cf9b3d71f4..e476810ac1 100644 --- a/e2e/config/mechanisms_stellar.json +++ b/e2e/config/mechanisms_stellar.json @@ -20,6 +20,13 @@ "sdks": ["typescript"], "price": { "usd": "$0.001" }, "extensions": ["bazaar"] + }, + "/exact/stellar/upfront": { + "scheme": "exact", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_svm.json b/e2e/config/mechanisms_svm.json index 84143aaea6..be4e329a81 100644 --- a/e2e/config/mechanisms_svm.json +++ b/e2e/config/mechanisms_svm.json @@ -2,11 +2,13 @@ "env": { "SERVER_SVM_ADDRESS": { "required": true, "roles": ["server"] }, "SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY": { - "required": true, + "required": false, "roles": ["server"] }, "CLIENT_SVM_PRIVATE_KEY": { "required": true, "roles": ["client"] }, - "FACILITATOR_SVM_PRIVATE_KEY": { "required": true, "roles": ["facilitator"] } + "FACILITATOR_SVM_PRIVATE_KEY": { "required": true, "roles": ["facilitator"] }, + "SWIG_ACCOUNT_ADDRESS": { "required": false, "roles": ["client"] }, + "SWIG_ID_BASE58": { "required": false, "roles": ["client"] } }, "testnet": { "name": "Solana Devnet", @@ -25,9 +27,18 @@ "price": { "usd": "$0.001" }, "extensions": ["bazaar"] }, + "/exact/svm/upfront": { + "scheme": "exact", + "sdks": ["typescript", "go", "python"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] + }, "/upto/svm": { "scheme": "upto", + "requiresEnv": "SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", "sdks": ["typescript", "go"], + "paymentFlow": "escrow", "price": { "usd": "$0.002" }, "extensions": ["bazaar"], "settlementOverride": { "amount": "1000" } diff --git a/e2e/config/mechanisms_tvm.json b/e2e/config/mechanisms_tvm.json index 84da15e8a6..9bc7894999 100644 --- a/e2e/config/mechanisms_tvm.json +++ b/e2e/config/mechanisms_tvm.json @@ -23,6 +23,13 @@ "sdks": ["typescript", "python"], "price": { "usd": "$0.001" }, "extensions": ["bazaar"] + }, + "/exact/tvm/upfront": { + "scheme": "exact", + "sdks": ["typescript", "python"], + "paymentFlow": "upfront", + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"] } } } diff --git a/e2e/config/mechanisms_xrpl.json b/e2e/config/mechanisms_xrpl.json index a2ed753cfa..cc1f05f8fb 100644 --- a/e2e/config/mechanisms_xrpl.json +++ b/e2e/config/mechanisms_xrpl.json @@ -35,6 +35,25 @@ }, "extensions": ["bazaar"] }, + "/exact/xrpl/sequence/upfront": { + "scheme": "exact", + "assetTransferMethod": "sequence", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { + "amount": "1000", + "amountEnv": "SERVER_XRPL_AMOUNT", + "asset": "XRP", + "assetEnv": "SERVER_XRPL_ASSET", + "extraEnv": { + "issuer": { + "env": "SERVER_XRPL_ISSUER", + "whenAssetOverridden": true + } + } + }, + "extensions": ["bazaar"] + }, "/exact/xrpl/ticketSequence": { "scheme": "exact", "assetTransferMethod": "ticketSequence", @@ -52,6 +71,25 @@ } }, "extensions": ["bazaar"] + }, + "/exact/xrpl/ticketSequence/upfront": { + "scheme": "exact", + "assetTransferMethod": "ticketSequence", + "sdks": ["typescript"], + "paymentFlow": "upfront", + "price": { + "amount": "1000", + "amountEnv": "SERVER_XRPL_AMOUNT", + "asset": "XRP", + "assetEnv": "SERVER_XRPL_ASSET", + "extraEnv": { + "issuer": { + "env": "SERVER_XRPL_ISSUER", + "whenAssetOverridden": true + } + } + }, + "extensions": ["bazaar"] } } } diff --git a/e2e/facilitators/go/main.go b/e2e/facilitators/go/main.go index da509c3c24..595e6d5d42 100644 --- a/e2e/facilitators/go/main.go +++ b/e2e/facilitators/go/main.go @@ -706,7 +706,7 @@ func (s *realFacilitatorSvmSigner) SimulateTransaction(ctx context.Context, tx * } opts := rpc.SimulateTransactionOpts{ - SigVerify: true, + SigVerify: false, ReplaceRecentBlockhash: false, Commitment: svmmech.DefaultCommitment, } @@ -785,12 +785,114 @@ func (s *realFacilitatorSvmSigner) ConfirmTransaction(ctx context.Context, signa } // Wait before retrying - time.Sleep(svmmech.ConfirmRetryDelay) + delay := svmmech.ConfirmRetryDelay + if attempt < svmmech.ConfirmInitialAttempts { + delay = svmmech.ConfirmInitialRetryDelay + } + time.Sleep(delay) } return fmt.Errorf("transaction confirmation timed out after %d attempts", svmmech.MaxConfirmAttempts) } +func (s *realFacilitatorSvmSigner) GetAccountInfo( + ctx context.Context, + account solana.PublicKey, + network string, + opts *rpc.GetAccountInfoOpts, +) (*rpc.GetAccountInfoResult, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return rpcClient.GetAccountInfoWithOpts(ctx, account, opts) +} + +func (s *realFacilitatorSvmSigner) GetLatestBlockhash(ctx context.Context, network string) (solana.Hash, uint64, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return solana.Hash{}, 0, err + } + latest, err := rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return solana.Hash{}, 0, err + } + return latest.Value.Blockhash, latest.Value.LastValidBlockHeight, nil +} + +func (s *realFacilitatorSvmSigner) GetSlot(ctx context.Context, network string, commitment rpc.CommitmentType) (uint64, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, err + } + return rpcClient.GetSlot(ctx, commitment) +} + +func (s *realFacilitatorSvmSigner) SimulateTransactionWithOpts( + ctx context.Context, + tx *solana.Transaction, + network string, + opts *rpc.SimulateTransactionOpts, +) error { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return err + } + result, err := rpcClient.SimulateTransactionWithOpts(ctx, tx, opts) + if err != nil { + return fmt.Errorf("simulation failed: %w", err) + } + if result != nil && result.Value != nil && result.Value.Err != nil { + return fmt.Errorf("simulation failed: transaction would fail on-chain") + } + return nil +} + +func (s *realFacilitatorSvmSigner) GetProgramAccounts( + ctx context.Context, + network string, + programID solana.PublicKey, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return rpcClient.GetProgramAccountsWithOpts(ctx, programID, opts) +} + +func (s *realFacilitatorSvmSigner) SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, network string) ([]rpc.InnerInstruction, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.SimulateWithInnerInstructions(ctx, rpcClient, tx) +} + +func (s *realFacilitatorSvmSigner) GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, network string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, nil, err + } + return svmmech.ConfirmedTransactionInnerInstructions(ctx, rpcClient, signature) +} + +func (s *realFacilitatorSvmSigner) GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, network string) (uint64, bool, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, false, err + } + return svmmech.TokenAccountBalance(ctx, rpcClient, tokenAccount) +} + +func (s *realFacilitatorSvmSigner) FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, network string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.AddressLookupTables(ctx, rpcClient, tables) +} + func (s *realFacilitatorSvmSigner) GetAddresses(ctx context.Context, network string) []solana.PublicKey { return []solana.PublicKey{s.privateKey.PublicKey()} } @@ -988,10 +1090,13 @@ func main() { svmAddresses := svmSigner.GetAddresses(context.Background(), svmNetwork) log.Printf("SVM Facilitator account: %s", svmAddresses[0].String()) - facilitator.Register([]x402.Network{x402.Network(svmNetwork)}, svm.NewExactSvmScheme(svmSigner)) facilitator.Register( []x402.Network{x402.Network(svmNetwork)}, - uptosvm.NewUptoSvmScheme(svmSigner, &uptosvm.Config{RPCURL: svmRpcUrl}), + svm.NewExactSvmScheme(svmSigner, &svm.Config{EnableSmartWalletVerification: true}), + ) + facilitator.Register( + []x402.Network{x402.Network(svmNetwork)}, + uptosvm.NewUptoSvmScheme(svmSigner, nil), ) facilitator.RegisterV1( []x402.Network{x402.Network(getV1SvmNetwork(svmNetwork))}, diff --git a/e2e/facilitators/python/uv.lock b/e2e/facilitators/python/uv.lock index 7788c696aa..9dcf67144f 100644 --- a/e2e/facilitators/python/uv.lock +++ b/e2e/facilitators/python/uv.lock @@ -11,6 +11,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + [[package]] name = "abnf" version = "2.7.0" @@ -3039,58 +3043,27 @@ wheels = [ name = "solana" version = "0.38.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "aiolimiter", marker = "python_full_version < '3.11'" }, - { name = "construct-typing", marker = "python_full_version < '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "aiolimiter" }, + { name = "construct-typing" }, + { name = "httpx2", extra = ["http2"] }, + { name = "pydantic" }, + { name = "solders" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/74/9f0621b79059d1e8c010d63514316fb691d94f0c408a215fb71173c5a896/solana-0.38.0.tar.gz", hash = "sha256:ca2c6441483ce5c0ce57d3538ae35cbe04e9c79e6cfc76942799746bfd9ff2d6", size = 498247, upload-time = "2026-06-22T01:42:08.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/1d/4f1d03735b33f32f5223026c45240c61c0730de598f78f1dcbb129389443/solana-0.38.0-py3-none-any.whl", hash = "sha256:396360b01b7ec463c3c92f25450eaf264202fd629f78054bb935ebb0a0410ff5", size = 73328, upload-time = "2026-06-22T01:42:07.509Z" }, ] -[[package]] -name = "solana" -version = "0.40.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "aiolimiter", marker = "python_full_version >= '3.11'" }, - { name = "construct-typing", marker = "python_full_version >= '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/f1728d9f44c479dbbf9d46814511d1a2b46affb314f72bf5810e7b0facd6/solana-0.40.1.tar.gz", hash = "sha256:75fd05aabba2dfeb4f6810e785e4aded3e119daee9cc8752584f244d82ef2c11", size = 474358, upload-time = "2026-07-17T00:05:08.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/af/64ac2e9bc18d4f75e946af41d605213c82c3fa75525837bbe68f68cc7211/solana-0.40.1-py3-none-any.whl", hash = "sha256:25a093bf2b5dfa439a99390088878feca0694703b8dd2dc6786ed84c72da1c20", size = 48305, upload-time = "2026-07-17T00:05:06.828Z" }, -] - [[package]] name = "solders" version = "0.27.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "jsonalias", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "jsonalias" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } wheels = [ @@ -3104,32 +3077,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, ] -[[package]] -name = "solders" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "jsonalias", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/b5/739f342aecacedf2a55e7baaff10714f1d70b4a4ccf2df4155fbe85a5a38/solders-0.28.0.tar.gz", hash = "sha256:c910d4fcc97bb00ec0146425b73a74e51241865de743be215b6c2fb664040013", size = 174557, upload-time = "2026-07-01T17:29:31.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/35/2ea73d0f6b7701a6747991842f76addb506dcc1cdcd1c310a6543e214d68/solders-0.28.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:07844e22f7a7b8fd698a46f2a09d355d35d9898a1bd86017ba6cb519eb11e4c1", size = 23084694, upload-time = "2026-07-01T17:29:14.522Z" }, - { url = "https://files.pythonhosted.org/packages/57/9e/cfdd9eea38d221fc72844b030ac3bb3e5dc60758aaf023dc77482e76019e/solders-0.28.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015ae6841cea52539521bb76c121df5742a476d1ae3718f1ddcd0d00f015fa59", size = 12056383, upload-time = "2026-07-01T17:29:18.016Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cb/565ac41638d925516cf8898f5fb574f2f9c4110159af5e2da7d4cb163dbd/solders-0.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad8c3f4516cb2c0214763766b10c065b64cdda1efed9440319aceac3cdc2615", size = 12513925, upload-time = "2026-07-01T17:29:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/4f/96/b8a74e55a0c978c776a30decc64266d56525eca89d5460c4965bf1c05735/solders-0.28.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cd3635e478765c279788b5a0875c0be19e72ec5ac37f36659479d02f47287594", size = 12629581, upload-time = "2026-07-01T17:29:23.654Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a7/5a6385537c6ae1071583b3538f1c40a8377d0b814d21505d5ccd535383cb/solders-0.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e21fd158660865f46dd93f0986f0606973f91833c723dc6f42c90e76aeaacf4d", size = 12663803, upload-time = "2026-07-01T17:29:26.594Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/6a6eb6e944c00579555de72a81bfb8191299e05aa2f1fbd28837179e6110/solders-0.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:38eceb69ecb514cb8b5ee9a7a80434f147ab0655094ab2bfc685b31a115acdc4", size = 11152191, upload-time = "2026-07-01T17:29:29.269Z" }, -] - [[package]] name = "starlette" version = "1.6.0" @@ -3552,7 +3499,7 @@ wheels = [ [[package]] name = "x402" -version = "2.19.0" +version = "2.21.0" source = { editable = "../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3579,10 +3526,8 @@ fastapi = [ { name = "starlette" }, ] svm = [ - { name = "solana", version = "0.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solana", version = "0.40.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "solana" }, + { name = "solders" }, ] tvm = [ { name = "httpx" }, diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index c4ea8ae67e..bb62dd4a94 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -423,7 +423,11 @@ const bazaarCatalog = new BazaarCatalog(); function createPaymentHash(paymentPayload: PaymentPayload): string { return crypto .createHash("sha256") - .update(JSON.stringify(paymentPayload)) + .update( + JSON.stringify(paymentPayload, (_, value) => + typeof value === "bigint" ? value.toString() : value, + ), + ) .digest("hex"); } @@ -587,7 +591,7 @@ if (svmSigner) { ) .register( SVM_NETWORK as Network, - new UptoSvmScheme(svmSigner, { rpcUrl: SVM_RPC_URL }), + new UptoSvmScheme(svmSigner), ) .registerV1(SVM_V1_NETWORKS as Network[], new ExactSvmSchemeV1(svmSigner)); } diff --git a/e2e/package.json b/e2e/package.json index fdd819a51f..67dd35e725 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -16,18 +16,30 @@ "permit2:approve": "tsx scripts/permit2-approval.ts approve", "permit2:revoke": "tsx scripts/permit2-approval.ts revoke", "swig:setup": "tsx scripts/swig-setup.ts", - "xrpl:iou:setup": "tsx scripts/xrpl-iou-setup.ts" + "xrpl:iou:setup": "tsx scripts/xrpl-iou-setup.ts", + "wallet:status": "tsx scripts/print-wallet-status.ts" }, "dependencies": { + "@aptos-labs/ts-sdk": "^5.2.1", "@concordium/web-sdk": "^12.0.0", + "@keetanetwork/keetanet-client": "^0.18.1", "@modelcontextprotocol/sdk": "^1.15.1", "@scure/base": "^1.2.6", "@solana-program/token-2022": "^0.6.1", "@solana/kit": "^6.1.0", "@swig-wallet/kit": "^2.0.0", "@swig-wallet/lib": "^2.0.0", + "@x402/aptos": "workspace:*", + "@x402/avm": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "@x402/hedera": "workspace:*", + "@x402/keeta": "workspace:*", + "@x402/near": "workspace:*", + "@x402/stellar": "workspace:*", + "@x402/svm": "workspace:*", + "@x402/tvm": "workspace:*", "dotenv": "^17.0.1", "prompts": "^2.4.2", "viem": "^2.48.11", diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index a0f0fe9cae..c25acd2e73 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -10,9 +10,15 @@ importers: .: dependencies: + '@aptos-labs/ts-sdk': + specifier: ^5.2.1 + version: 5.2.1(got@11.8.6) '@concordium/web-sdk': specifier: ^12.0.0 version: 12.0.2(@protobuf-ts/runtime-rpc@2.11.1)(cbor2@1.12.0) + '@keetanetwork/keetanet-client': + specifier: ^0.18.1 + version: 0.18.1 '@modelcontextprotocol/sdk': specifier: ^1.15.1 version: 1.25.3(hono@4.10.2)(zod@4.1.12) @@ -31,12 +37,39 @@ importers: '@swig-wallet/lib': specifier: ^2.0.0 version: 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@x402/aptos': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/aptos + '@x402/avm': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/avm '@x402/concordium': specifier: workspace:* version: link:../typescript/packages/mechanisms/concordium '@x402/core': specifier: workspace:* version: link:../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/evm + '@x402/hedera': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/hedera + '@x402/keeta': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/keeta + '@x402/near': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/near + '@x402/stellar': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/stellar + '@x402/svm': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/svm + '@x402/tvm': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/tvm dotenv: specifier: ^17.0.1 version: 17.0.1 @@ -1731,6 +1764,9 @@ importers: ../typescript/packages/mechanisms/svm: dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@solana-program/compute-budget': specifier: ^0.11.0 version: 0.11.0(@solana/kit@5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)) diff --git a/e2e/scripts/print-wallet-status.ts b/e2e/scripts/print-wallet-status.ts new file mode 100644 index 0000000000..6b02a7b5f3 --- /dev/null +++ b/e2e/scripts/print-wallet-status.ts @@ -0,0 +1,811 @@ +/** + * Print facilitator / client / server addresses and key balances for every + * protocol family whose catalog-required env keys are set. + * + * Usage: + * pnpm wallet:status + * pnpm wallet:status --mainnet + * + * Loads e2e/.env (does not override existing exports). Never prints keys. + */ + +import { appendFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { Account, Ed25519PrivateKey, PrivateKey, PrivateKeyVariants } from "@aptos-labs/ts-sdk"; +import { AccountAddress, CcdAmount } from "@concordium/web-sdk"; +import { ConcordiumGRPCNodeClient, credentials } from "@concordium/web-sdk/nodejs"; +import * as KeetaNet from "@keetanetwork/keetanet-client"; +import { base58 } from "@scure/base"; +import { + createKeyPairSignerFromBytes, + createSolanaRpc, + type Address as SvmAddress, +} from "@solana/kit"; +import { getDefaultAsset as getAptosDefaultAsset } from "@x402/aptos"; +import { getDefaultAsset as getAvmDefaultAsset, toClientAvmSigner } from "@x402/avm"; +import { + getConcordiumGrpcUrl, + parseGrpcUrl, + CCD_DECIMALS, +} from "@x402/concordium"; +import { getDefaultAsset as getEvmDefaultAsset } from "@x402/evm"; +import { + HBAR_ASSET_ID, + HEDERA_MAINNET_MIRROR_NODE_URL, + HEDERA_TESTNET_MIRROR_NODE_URL, +} from "@x402/hedera"; +import { getDefaultAsset as getKeetaDefaultAsset, networkToKeetaNetwork } from "@x402/keeta"; +import { getDefaultAsset as getNearDefaultAsset } from "@x402/near"; +import { + createEd25519Signer, + DEFAULT_PUBNET_HORIZON_URL, + DEFAULT_TESTNET_HORIZON_URL, +} from "@x402/stellar"; +import { getDefaultAsset as getSvmDefaultAsset } from "@x402/svm"; +import { + createTvmProviderClient, + getDefaultAsset as getTvmDefaultAsset, + HighloadV3Config, + toClientTvmSigner, + toFacilitatorTvmSigner, + TVM_PROVIDER_TONAPI, + TVM_PROVIDER_TONCENTER, +} from "@x402/tvm"; +import { config } from "dotenv"; +import { createPublicClient, formatEther, formatUnits, http, parseAbi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, baseSepolia } from "viem/chains"; +import { Client, Wallet } from "xrpl"; +import { + PROTOCOL_FAMILIES, + getNetworkForProtocol, + requiredEnvForFamily, + serverAddressKey, + type NetworkMode, + type ProtocolFamily, +} from "../src/networks/networks"; + +const e2eDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +config({ path: join(e2eDir, ".env") }); + +const FETCH_TIMEOUT_MS = 15_000; +const ERC20_BALANCE_ABI = parseAbi([ + "function balanceOf(address account) view returns (uint256)", +]); + +type Balance = { + symbol: string; + formatted: string; +}; + +type RoleRow = { + role: "facilitator" | "client" | "server"; + address: string; + balance?: Balance; +}; + +type FamilyReport = { + family: string; + networkName: string; + caip2: string; + error?: string; + rows: RoleRow[]; +}; + +function env(key: string): string | undefined { + const value = process.env[key]?.trim(); + return value ? value : undefined; +} + +function requireEnv(key: string): string { + const value = env(key); + if (!value) { + throw new Error(`${key} is not set`); + } + return value; +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message.split("\n")[0]?.trim() || err.message; + } + return String(err); +} + +function formatAmount(raw: bigint, decimals: number): string { + const negative = raw < 0n; + const value = negative ? -raw : raw; + const base = 10n ** BigInt(decimals); + const whole = value / base; + const frac = value % base; + if (frac === 0n) { + return `${negative ? "-" : ""}${whole}`; + } + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + return `${negative ? "-" : ""}${whole}.${fracStr}`; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText} (${url})`); + } + return response.json(); +} + +async function rpcJson(url: string, method: string, params: unknown[]): Promise { + const body = await fetchJson(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + if (!body || typeof body !== "object") { + throw new Error(`${method} returned a non-object`); + } + const payload = body as { error?: { message?: string }; result?: unknown }; + if (payload.error) { + throw new Error(payload.error.message ?? `${method} RPC error`); + } + return payload.result; +} + +/** NEAR JSON-RPC expects `params` as a single object, not an array. */ +async function nearRpcJson(url: string, method: string, params: Record): Promise { + const body = await fetchJson(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + if (!body || typeof body !== "object") { + throw new Error(`${method} returned a non-object`); + } + const payload = body as { error?: { message?: string }; result?: unknown }; + if (payload.error) { + throw new Error(payload.error.message ?? `${method} RPC error`); + } + return payload.result; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseTvmKeyPair(privateKey: string): { publicKey: Buffer; secretKey: Buffer } { + const value = privateKey.trim().replace(/^0x/, ""); + let bytes: Buffer; + if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) { + bytes = Buffer.from(value, "hex"); + } else { + bytes = Buffer.from(value, "base64"); + } + if (bytes.length !== 32 && bytes.length !== 64) { + throw new Error("TVM private key must be a 32-byte seed or 64-byte secret key"); + } + const seed = bytes.subarray(0, 32); + if (bytes.length === 64) { + return { publicKey: bytes.subarray(32), secretKey: bytes }; + } + const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex"); + const keyObject = createPrivateKey({ + key: Buffer.concat([pkcs8Prefix, seed]), + format: "der", + type: "pkcs8", + }); + const spki = createPublicKey(keyObject).export({ type: "spki", format: "der" }) as Buffer; + const publicKey = spki.subarray(spki.length - 32); + return { publicKey, secretKey: Buffer.concat([seed, publicKey]) }; +} + +async function withBalance( + symbol: string, + query: () => Promise, +): Promise { + try { + return { symbol, formatted: await query() }; + } catch (err) { + return { symbol, formatted: `error: ${errorMessage(err)}` }; + } +} + +function aptosAccountAddress(privateKey: string): string { + const formattedKey = PrivateKey.formatPrivateKey(privateKey, PrivateKeyVariants.Ed25519); + const account = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(formattedKey), + }); + return account.accountAddress.toStringLong(); +} + +async function aptosBalance(rpcUrl: string, address: string, asset: string): Promise { + const result = await fetchJson( + `${rpcUrl.replace(/\/$/, "")}/accounts/${address}/balance/${encodeURIComponent(asset)}`, + ); + if (typeof result === "string" || typeof result === "number") { + return String(result); + } + if (isRecord(result) && result.data !== undefined) { + return String(result.data); + } + throw new Error("unexpected Aptos balance response"); +} + +async function reportEvm(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "evm"); + const chain = net.caip2 === "eip155:8453" ? base : baseSepolia; + const facilitator = privateKeyToAccount(requireEnv("FACILITATOR_EVM_PRIVATE_KEY") as `0x${string}`); + const client = privateKeyToAccount(requireEnv("CLIENT_EVM_PRIVATE_KEY") as `0x${string}`); + const server = requireEnv(serverAddressKey("evm")); + const publicClient = createPublicClient({ + chain, + transport: http(net.rpcUrl || undefined), + }); + const usdc = getEvmDefaultAsset(net.caip2); + const native = await withBalance("ETH", async () => + formatEther(await publicClient.getBalance({ address: facilitator.address })), + ); + const payment = await withBalance(usdc.symbol, async () => { + const raw = await publicClient.readContract({ + address: usdc.asset as `0x${string}`, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: [client.address], + }); + return formatUnits(raw, usdc.decimals); + }); + return { + family: "evm", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator.address, balance: native }, + { role: "client", address: client.address, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportSvm(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "svm"); + const facilitator = await createKeyPairSignerFromBytes( + base58.decode(requireEnv("FACILITATOR_SVM_PRIVATE_KEY")), + ); + const client = await createKeyPairSignerFromBytes( + base58.decode(requireEnv("CLIENT_SVM_PRIVATE_KEY")), + ); + const server = requireEnv(serverAddressKey("svm")); + const rpc = createSolanaRpc(net.rpcUrl); + const usdc = getSvmDefaultAsset(net.caip2); + const native = await withBalance("SOL", async () => { + const { value } = await rpc.getBalance(facilitator.address as SvmAddress).send(); + return formatAmount(value, 9); + }); + const payment = await withBalance(usdc.symbol, async () => { + const result = await rpcJson(net.rpcUrl, "getTokenAccountsByOwner", [ + client.address, + { mint: usdc.asset }, + { encoding: "jsonParsed" }, + ]); + if (!isRecord(result) || !Array.isArray(result.value) || result.value.length === 0) { + return "0"; + } + const first = result.value[0]; + if (!isRecord(first) || !isRecord(first.account) || !isRecord(first.account.data)) { + throw new Error("unexpected token-account encoding"); + } + const parsed = first.account.data.parsed; + if (!isRecord(parsed) || !isRecord(parsed.info) || !isRecord(parsed.info.tokenAmount)) { + throw new Error("unexpected token-account parsed info"); + } + const tokenAmount = parsed.info.tokenAmount; + if (typeof tokenAmount.uiAmountString === "string") { + return tokenAmount.uiAmountString; + } + if (typeof tokenAmount.amount === "string") { + return formatAmount(BigInt(tokenAmount.amount), Number(tokenAmount.decimals ?? usdc.decimals)); + } + throw new Error("missing token amount"); + }); + return { + family: "svm", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator.address, balance: native }, + { role: "client", address: client.address, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportAvm(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "avm"); + const facilitator = toClientAvmSigner(requireEnv("FACILITATOR_AVM_PRIVATE_KEY")); + const client = toClientAvmSigner(requireEnv("CLIENT_AVM_PRIVATE_KEY")); + const server = requireEnv(serverAddressKey("avm")); + const usdc = getAvmDefaultAsset(net.caip2); + const accountUrl = `${net.rpcUrl.replace(/\/$/, "")}/v2/accounts`; + const native = await withBalance("ALGO", async () => { + const body = await fetchJson(`${accountUrl}/${facilitator.address}`); + if (!isRecord(body) || typeof body.amount !== "number") { + throw new Error("unexpected Algorand account response"); + } + return formatAmount(BigInt(body.amount), 6); + }); + const payment = await withBalance(usdc.symbol, async () => { + const body = await fetchJson(`${accountUrl}/${client.address}`); + if (!isRecord(body)) { + throw new Error("unexpected Algorand account response"); + } + const assets = Array.isArray(body.assets) ? body.assets : []; + const asaId = Number(usdc.asset); + for (const asset of assets) { + if (!isRecord(asset)) continue; + if (Number(asset["asset-id"]) === asaId) { + return formatAmount(BigInt(Number(asset.amount)), usdc.decimals); + } + } + return "0"; + }); + return { + family: "avm", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator.address, balance: native }, + { role: "client", address: client.address, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportAptos(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "aptos"); + const facilitator = aptosAccountAddress(requireEnv("FACILITATOR_APTOS_PRIVATE_KEY")); + const client = aptosAccountAddress(requireEnv("CLIENT_APTOS_PRIVATE_KEY")); + const server = requireEnv(serverAddressKey("aptos")); + const usdc = getAptosDefaultAsset(net.caip2); + const native = await withBalance("APT", async () => { + const raw = await aptosBalance(net.rpcUrl, facilitator, "0x1::aptos_coin::AptosCoin"); + return formatAmount(BigInt(raw), 8); + }); + const payment = await withBalance(usdc.symbol, async () => { + const raw = await aptosBalance(net.rpcUrl, client, usdc.asset); + return formatAmount(BigInt(raw), usdc.decimals); + }); + return { + family: "aptos", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportCcd(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "ccd"); + const facilitator = requireEnv("FACILITATOR_CCD_ADDRESS"); + const client = requireEnv("CLIENT_CCD_ADDRESS"); + const server = requireEnv(serverAddressKey("ccd")); + const grpcUrl = net.rpcUrl || getConcordiumGrpcUrl(net.caip2); + const [host, port] = parseGrpcUrl(grpcUrl); + const grpc = new ConcordiumGRPCNodeClient(host, port, credentials.createSsl()); + const ccdBalance = async (address: string): Promise => { + const info = await grpc.getAccountInfo(AccountAddress.fromBase58(address)); + const micro = CcdAmount.toMicroCcd(info.accountAmount); + return formatAmount(BigInt(String(micro)), CCD_DECIMALS); + }; + const native = await withBalance("CCD", () => ccdBalance(facilitator)); + const payment = await withBalance("CCD", () => ccdBalance(client)); + return { + family: "ccd", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportHedera(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "hedera"); + const facilitator = requireEnv("FACILITATOR_HEDERA_ACCOUNT_ID"); + const client = requireEnv("CLIENT_HEDERA_ACCOUNT_ID"); + const server = requireEnv(serverAddressKey("hedera")); + const mirror = + mode === "mainnet" ? HEDERA_MAINNET_MIRROR_NODE_URL : HEDERA_TESTNET_MIRROR_NODE_URL; + const paymentAsset = env("HEDERA_ASSET") ?? HBAR_ASSET_ID; + const accountBalance = async (accountId: string): Promise> => { + const body = await fetchJson(`${mirror}/api/v1/accounts/${accountId}`); + if (!isRecord(body) || !isRecord(body.balance)) { + throw new Error("unexpected Hedera mirror account response"); + } + return body.balance; + }; + const native = await withBalance("HBAR", async () => { + const balance = await accountBalance(facilitator); + return formatAmount(BigInt(String(balance.balance ?? 0)), 8); + }); + const paymentSymbol = paymentAsset === HBAR_ASSET_ID ? "HBAR" : paymentAsset; + const payment = await withBalance(paymentSymbol, async () => { + const balance = await accountBalance(client); + if (paymentAsset === HBAR_ASSET_ID) { + return formatAmount(BigInt(String(balance.balance ?? 0)), 8); + } + const tokens = Array.isArray(balance.tokens) ? balance.tokens : []; + for (const token of tokens) { + if (isRecord(token) && token.token_id === paymentAsset) { + return String(token.balance ?? 0); + } + } + return "0"; + }); + return { + family: "hedera", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function keetaAccountFromMnemonic(mnemonic: string): Promise { + const seed = await KeetaNet.lib.Account.seedFromPassphrase(mnemonic); + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + return account.publicKeyString.toString(); +} + +async function reportKeeta(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "keeta"); + const facilitator = await keetaAccountFromMnemonic(requireEnv("FACILITATOR_KEETA_MNEMONIC")); + const client = await keetaAccountFromMnemonic(requireEnv("CLIENT_KEETA_MNEMONIC")); + const server = requireEnv(serverAddressKey("keeta")); + const keetaNetwork = networkToKeetaNetwork(net.caip2); + const usdc = getKeetaDefaultAsset(net.caip2); + const userClient = KeetaNet.UserClient.fromNetwork(keetaNetwork, null); + try { + const native = await withBalance("KTA", async () => { + const raw = await userClient.client.getBalance(facilitator, userClient.baseToken); + return formatAmount(BigInt(String(raw)), 9); + }); + const payment = await withBalance(usdc.symbol, async () => { + const token = KeetaNet.lib.Account.fromPublicKeyString(usdc.asset); + const raw = await userClient.client.getBalance(client, token); + return formatAmount(BigInt(String(raw)), usdc.decimals); + }); + return { + family: "keeta", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; + } finally { + await userClient.destroy(); + } +} + +function stellarHorizonUrl(mode: NetworkMode): string { + return mode === "mainnet" ? DEFAULT_PUBNET_HORIZON_URL : DEFAULT_TESTNET_HORIZON_URL; +} + +async function reportStellar(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "stellar"); + const facilitator = createEd25519Signer(requireEnv("FACILITATOR_STELLAR_PRIVATE_KEY"), net.caip2); + const client = createEd25519Signer(requireEnv("CLIENT_STELLAR_PRIVATE_KEY"), net.caip2); + const server = requireEnv(serverAddressKey("stellar")); + const horizon = stellarHorizonUrl(mode); + const accountBalances = async (address: string): Promise => { + const body = await fetchJson(`${horizon}/accounts/${address}`); + if (!isRecord(body) || !Array.isArray(body.balances)) { + throw new Error("unexpected Horizon account response"); + } + return body.balances; + }; + const native = await withBalance("XLM", async () => { + const balances = await accountBalances(facilitator.address); + for (const entry of balances) { + if (isRecord(entry) && entry.asset_type === "native") { + return String(entry.balance ?? "0"); + } + } + return "0"; + }); + const payment = await withBalance("USDC", async () => { + const balances = await accountBalances(client.address); + for (const entry of balances) { + if (isRecord(entry) && entry.asset_code === "USDC") { + return String(entry.balance ?? "0"); + } + } + return "0"; + }); + return { + family: "stellar", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator.address, balance: native }, + { role: "client", address: client.address, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportTvm(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "tvm"); + const provider = (env("TVM_PROVIDER") || TVM_PROVIDER_TONCENTER).toLowerCase(); + const apiKey = + provider === TVM_PROVIDER_TONAPI ? env("TVM_TONAPI_API_KEY") : env("TVM_TONCENTER_API_KEY"); + const providerOpts = { + provider, + apiKey, + providerBaseUrl: net.rpcUrl || undefined, + }; + const facilitatorConfig = HighloadV3Config.fromPrivateKey( + requireEnv("FACILITATOR_TVM_PRIVATE_KEY"), + providerOpts, + ); + const facilitatorSigner = toFacilitatorTvmSigner({ [net.caip2]: facilitatorConfig }); + const facilitator = facilitatorSigner.getAddressesForNetwork(net.caip2)[0]; + const clientSigner = toClientTvmSigner( + parseTvmKeyPair(requireEnv("CLIENT_TVM_PRIVATE_KEY")) as Parameters< + typeof toClientTvmSigner + >[0], + { + network: net.caip2, + ...providerOpts, + }, + ); + const client = clientSigner.address; + const server = requireEnv(serverAddressKey("tvm")); + const usdt = getTvmDefaultAsset(net.caip2); + const tvmClient = createTvmProviderClient(net.caip2, { + provider, + apiKey, + baseUrl: net.rpcUrl || undefined, + }); + try { + const native = await withBalance("TON", async () => { + const state = await tvmClient.getAccountState(facilitator); + return formatAmount(state.balance, 9); + }); + const payment = await withBalance(usdt.symbol, async () => { + const wallet = await tvmClient.getJettonWallet(usdt.asset, client); + const data = await tvmClient.getJettonWalletData(wallet); + return formatAmount(data.balance, usdt.decimals); + }); + return { + family: "tvm", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; + } finally { + tvmClient.close(); + facilitatorSigner.close(); + } +} + +async function reportNear(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "near"); + const facilitator = requireEnv("FACILITATOR_NEAR_ACCOUNT_ID"); + const client = requireEnv("CLIENT_NEAR_ACCOUNT_ID"); + const server = requireEnv(serverAddressKey("near")); + const paymentAsset = env("SERVER_NEAR_ASSET") ?? "wrap.testnet"; + const native = await withBalance("NEAR", async () => { + const result = await nearRpcJson(net.rpcUrl, "query", { + request_type: "view_account", + finality: "final", + account_id: facilitator, + }); + if (!isRecord(result) || typeof result.amount !== "string") { + throw new Error("unexpected NEAR view_account response"); + } + return formatAmount(BigInt(result.amount), 24); + }); + const paymentIsWnear = paymentAsset === "wrap.testnet" || paymentAsset === "wrap.near"; + const paymentMeta = paymentIsWnear + ? { symbol: "wNEAR", decimals: 24 } + : getNearDefaultAsset(net.caip2); + const payment = await withBalance(paymentIsWnear ? "wNEAR" : paymentMeta.symbol, async () => { + const result = await nearRpcJson(net.rpcUrl, "query", { + request_type: "call_function", + finality: "final", + account_id: paymentAsset, + method_name: "ft_balance_of", + args_base64: Buffer.from(JSON.stringify({ account_id: client })).toString("base64"), + }); + if (!isRecord(result) || !Array.isArray(result.result)) { + throw new Error("unexpected NEAR ft_balance_of response"); + } + const decoded = Buffer.from(result.result as number[]).toString("utf8"); + const raw = JSON.parse(decoded) as string; + const decimals = paymentIsWnear ? 24 : paymentMeta.decimals; + return formatAmount(BigInt(raw), decimals); + }); + return { + family: "near", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitator, balance: native }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; +} + +async function reportXrpl(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "xrpl"); + const wallet = Wallet.fromSeed(requireEnv("CLIENT_XRPL_SEED")); + const client = wallet.classicAddress; + const server = requireEnv(serverAddressKey("xrpl")); + const asset = env("SERVER_XRPL_ASSET") ?? "XRP"; + const issuer = env("SERVER_XRPL_ISSUER"); + const xrpl = new Client(net.rpcUrl); + await xrpl.connect(); + try { + const payment = await withBalance(asset, async () => { + if (asset === "XRP") { + return await xrpl.getXrpBalance(client); + } + const lines = await xrpl.request({ + command: "account_lines", + account: client, + }); + for (const line of lines.result.lines) { + const issuerMatch = !issuer || line.account === issuer; + if (line.currency === asset && issuerMatch) { + return line.balance; + } + } + return "0"; + }); + return { + family: "xrpl", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: "(keyless)" }, + { role: "client", address: client, balance: payment }, + { role: "server", address: server }, + ], + }; + } finally { + await xrpl.disconnect(); + } +} + +const HANDLERS: Record Promise> = { + evm: reportEvm, + svm: reportSvm, + avm: reportAvm, + aptos: reportAptos, + ccd: reportCcd, + hedera: reportHedera, + keeta: reportKeeta, + stellar: reportStellar, + tvm: reportTvm, + near: reportNear, + xrpl: reportXrpl, +}; + +async function reportFamily(family: ProtocolFamily, mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, family); + const handler = HANDLERS[family]; + if (!handler) { + return { + family, + networkName: net.name, + caip2: net.caip2, + error: "no wallet-status handler for this family", + rows: [], + }; + } + try { + return await handler(mode); + } catch (err) { + return { + family, + networkName: net.name, + caip2: net.caip2, + error: errorMessage(err), + rows: [], + }; + } +} + +function formatText(mode: NetworkMode, reports: FamilyReport[]): string { + const lines = [`e2e wallet status (${mode})`, ""]; + for (const report of reports) { + lines.push(`${report.family} ${report.networkName} ${report.caip2}`); + if (report.error) { + lines.push(` error ${report.error}`); + lines.push(""); + continue; + } + for (const row of report.rows) { + const role = row.role.padEnd(12); + if (row.balance) { + lines.push( + ` ${role} ${row.address} ${row.balance.symbol} ${row.balance.formatted}`, + ); + } else { + lines.push(` ${role} ${row.address}`); + } + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +function formatMarkdown(mode: NetworkMode, reports: FamilyReport[]): string { + const lines = [`## e2e wallet status (${mode})`, ""]; + for (const report of reports) { + lines.push(`### ${report.family} — ${report.networkName} (\`${report.caip2}\`)`); + if (report.error) { + lines.push("", `error: ${report.error}`, ""); + continue; + } + lines.push("", "| Role | Address | Balance |", "| --- | --- | --- |"); + for (const row of report.rows) { + const balance = row.balance ? `${row.balance.symbol} ${row.balance.formatted}` : ""; + lines.push(`| ${row.role} | \`${row.address}\` | ${balance} |`); + } + lines.push(""); + } + return lines.join("\n"); +} + +function configuredFamilies(): ProtocolFamily[] { + return PROTOCOL_FAMILIES.filter(family => { + const keys = requiredEnvForFamily(family); + if (keys.length === 0) { + return false; + } + return keys.every(key => Boolean(env(key))); + }); +} + +async function main(): Promise { + const mode: NetworkMode = process.argv.includes("--mainnet") ? "mainnet" : "testnet"; + const families = configuredFamilies(); + if (families.length === 0) { + console.error("No protocol families have all required wallet secrets configured."); + console.error("Set variables in e2e/.env or export them in your shell."); + process.exit(1); + } + + const reports = await Promise.all(families.map(family => reportFamily(family, mode))); + console.log(formatText(mode, reports)); + + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + appendFileSync(summaryPath, `${formatMarkdown(mode, reports)}\n`); + } +} + +main().catch(err => { + console.error(errorMessage(err)); + process.exit(1); +}); + diff --git a/e2e/scripts/swig-setup.ts b/e2e/scripts/swig-setup.ts index 8998aa2d56..0d6a2b19af 100644 --- a/e2e/scripts/swig-setup.ts +++ b/e2e/scripts/swig-setup.ts @@ -18,11 +18,11 @@ * Funding uses the standard e2e exact price ($0.001 = 1000 base units). If Swig * balance is below one payment, tops up to 10× that amount. * - * On first Swig creation, persists SWIG_ACCOUNT_ADDRESS (and SWIG_ID_BASE58 when - * generated) to e2e/.env automatically. + * On success, always upserts SWIG_ACCOUNT_ADDRESS (and SWIG_ID_BASE58 when known) + * into e2e/.env so later CI steps and spawned clients can read the fixture. * * Prints a JSON result line on success: - * {"ok":true,"swigAccountAddress":"..."} + * {"ok":true,"swigAccountAddress":"...","created":true,"swigIdBase58":"..."} */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; @@ -40,11 +40,10 @@ import { appendTransactionMessageInstructions, createKeyPairSignerFromBytes, createSolanaRpc, - createSolanaRpcSubscriptions, createTransactionMessage, + getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, - sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, @@ -52,9 +51,7 @@ import { type Instruction, type KeyPairSigner, type Rpc, - type RpcSubscriptions, type SolanaRpcApi, - type SolanaRpcSubscriptionsApi, } from "@solana/kit"; import { fetchSwig, @@ -67,35 +64,55 @@ import { Actions, createEd25519AuthorityInfo } from "@swig-wallet/lib"; config(); const DEVNET_RPC_URL = "https://api.devnet.solana.com"; -const DEVNET_WS_URL = "wss://api.devnet.solana.com"; const USDC_DEVNET_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; const MIN_AUTHORITY_SOL = 5_000_000n; /** Standard e2e exact endpoint price: $0.001 USDC (6 decimals). */ const E2E_EXACT_PAYMENT_BASE_UNITS = 1_000n; const SWIG_FUND_MULTIPLIER = 10n; -type SwigConnection = { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; -}; +function resolveRpcUrl(rpcUrl?: string): string { + const trimmed = rpcUrl?.trim(); + return trimmed || DEVNET_RPC_URL; +} -function createConnection(rpcUrl?: string): SwigConnection { - const url = rpcUrl ?? DEVNET_RPC_URL; - const wsUrl = rpcUrl?.replace(/^http/i, "ws") ?? DEVNET_WS_URL; - return { - rpc: createSolanaRpc(url), - rpcSubscriptions: createSolanaRpcSubscriptions(wsUrl), - }; +function createRpc(rpcUrl?: string): Rpc { + return createSolanaRpc(resolveRpcUrl(rpcUrl)); +} + +/** Poll signature status over HTTP RPC (same approach as @x402/svm facilitator signer). */ +async function confirmTransaction(rpc: Rpc, signature: string): Promise { + const initialDelayMs = 250; + const initialWindowMs = 2_000; + const fallbackDelayMs = 1_000; + const maxWaitMs = 30_000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < maxWaitMs) { + const status = await rpc.getSignatureStatuses([signature as never]).send(); + const entry = status.value[0]; + + if (entry?.confirmationStatus === "confirmed" || entry?.confirmationStatus === "finalized") { + if (entry.err) { + throw new Error(`Transaction failed onchain: ${JSON.stringify(entry.err)}`); + } + return; + } + + const elapsed = Date.now() - startedAt; + const delay = elapsed < initialWindowMs ? initialDelayMs : fallbackDelayMs; + await new Promise(resolve => setTimeout(resolve, delay)); + } + + throw new Error("Transaction confirmation timeout"); } async function sendInstructions( - connection: SwigConnection, + rpc: Rpc, payer: KeyPairSigner, instructions: Instruction[], signers: KeyPairSigner[] = [], ): Promise { - const sendAndConfirm = sendAndConfirmTransactionFactory(connection); - const { value: latestBlockhash } = await connection.rpc.getLatestBlockhash().send(); + const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); const txMessage = pipe( createTransactionMessage({ version: 0 }), @@ -106,18 +123,26 @@ async function sendInstructions( ); const signedTx = await signTransactionMessageWithSigners(txMessage); - await sendAndConfirm(signedTx as Parameters[0], { - commitment: "confirmed", - }); - return getSignatureFromTransaction(signedTx); + const wireTransaction = getBase64EncodedWireTransaction(signedTx); + await rpc + .sendTransaction(wireTransaction, { + encoding: "base64", + skipPreflight: false, + preflightCommitment: "confirmed", + }) + .send(); + + const signature = getSignatureFromTransaction(signedTx); + await confirmTransaction(rpc, signature); + return signature; } async function requireSolBalance( - connection: SwigConnection, + rpc: Rpc, address: Address, minimumLamports: bigint, ): Promise { - const balance = await connection.rpc.getBalance(address).send(); + const balance = await rpc.getBalance(address).send(); if (balance.value >= minimumLamports) { return; } @@ -158,16 +183,32 @@ function persistSwigEnv(envPath: string, resolved: ResolvedSwigAccount): void { const updates: Record = { SWIG_ACCOUNT_ADDRESS: resolved.address, }; - if (resolved.swigIdBase58) { - updates.SWIG_ID_BASE58 = resolved.swigIdBase58; + const swigIdBase58 = resolved.swigIdBase58 ?? process.env.SWIG_ID_BASE58; + if (swigIdBase58) { + updates.SWIG_ID_BASE58 = swigIdBase58; } upsertEnvFile(envPath, updates); console.log(`💾 Saved Swig settings to ${envPath}`); } +/** CI-friendly lines so the next workflow step / operator can copy env values. */ +function printSwigEnvSummary(resolved: ResolvedSwigAccount): void { + if (resolved.created) { + console.log("\n✅ New Swig smart wallet created:"); + } else { + console.log("\n✅ Using existing Swig smart wallet:"); + } + console.log(` SWIG_ACCOUNT_ADDRESS=${resolved.address}`); + const swigIdBase58 = resolved.swigIdBase58 ?? process.env.SWIG_ID_BASE58; + if (swigIdBase58) { + console.log(` SWIG_ID_BASE58=${swigIdBase58}`); + } + console.log(""); +} + async function resolveSwigAccountAddress( - connection: SwigConnection, + rpc: Rpc, authority: KeyPairSigner, ): Promise { const fromEnv = process.env.SWIG_ACCOUNT_ADDRESS; @@ -176,7 +217,7 @@ async function resolveSwigAccountAddress( return { address: fromEnv as Address, created: false }; } - await requireSolBalance(connection, authority.address, MIN_AUTHORITY_SOL); + await requireSolBalance(rpc, authority.address, MIN_AUTHORITY_SOL); const swigIdFromEnv = process.env.SWIG_ID_BASE58; const swigId = swigIdFromEnv ? base58.decode(swigIdFromEnv) : (() => { @@ -193,7 +234,7 @@ async function resolveSwigAccountAddress( }); console.log(`🔄 Creating Swig account ${swigAccountAddress}...`); - const sig = await sendInstructions(connection, authority, [createSwigIx as Instruction]); + const sig = await sendInstructions(rpc, authority, [createSwigIx as Instruction]); console.log(` ✅ Swig create tx: ${sig}`); return { @@ -204,15 +245,15 @@ async function resolveSwigAccountAddress( } async function ensureSwigFunded( - connection: SwigConnection, + rpc: Rpc, authority: KeyPairSigner, swigAccountAddress: Address, mint: Address, ): Promise { - const swig = await fetchSwig(connection.rpc as never, swigAccountAddress); + const swig = await fetchSwig(rpc as never, swigAccountAddress); const swigWalletAddress = await getSwigWalletAddress(swig); - const mintInfo = await fetchMint(connection.rpc, mint); + const mintInfo = await fetchMint(rpc, mint); const tokenProgram = mintInfo.programAddress; const decimals = mintInfo.data.decimals; @@ -241,20 +282,20 @@ async function ensureSwigFunded( }); try { - await connection.rpc.getTokenAccountBalance(authorityAta).send(); + await rpc.getTokenAccountBalance(authorityAta).send(); } catch { console.log("🔄 Creating authority USDC ATA..."); - await sendInstructions(connection, authority, [createAuthorityAtaIx]); + await sendInstructions(rpc, authority, [createAuthorityAtaIx]); } try { - await connection.rpc.getTokenAccountBalance(swigAta).send(); + await rpc.getTokenAccountBalance(swigAta).send(); } catch { console.log("🔄 Creating Swig wallet USDC ATA..."); - await sendInstructions(connection, authority, [createSwigAtaIx]); + await sendInstructions(rpc, authority, [createSwigAtaIx]); } - const swigBalance = await connection.rpc.getTokenAccountBalance(swigAta).send(); + const swigBalance = await rpc.getTokenAccountBalance(swigAta).send(); const swigAmount = BigInt(swigBalance.value.amount); const fundTarget = E2E_EXACT_PAYMENT_BASE_UNITS * SWIG_FUND_MULTIPLIER; @@ -266,7 +307,7 @@ async function ensureSwigFunded( } const topUpAmount = fundTarget - swigAmount; - const authorityBalance = await connection.rpc.getTokenAccountBalance(authorityAta).send(); + const authorityBalance = await rpc.getTokenAccountBalance(authorityAta).send(); if (BigInt(authorityBalance.value.amount) < topUpAmount) { throw new Error( "Authority USDC balance too low. Fund CLIENT_SVM_PRIVATE_KEY with devnet USDC " + @@ -286,7 +327,7 @@ async function ensureSwigFunded( }, { programAddress: tokenProgram }, ); - const sig = await sendInstructions(connection, authority, [fundIx]); + const sig = await sendInstructions(rpc, authority, [fundIx]); console.log(` ✅ Fund tx: ${sig}`); } @@ -297,23 +338,29 @@ async function main(): Promise { process.exit(1); } - const rpcUrl = process.env.SVM_RPC_URL; + const rpcUrl = resolveRpcUrl(process.env.SVM_RPC_URL); const mint = (process.env.SVM_USDC_MINT ?? USDC_DEVNET_MINT) as Address; - const connection = createConnection(rpcUrl); + const rpc = createRpc(rpcUrl); const authority = await createKeyPairSignerFromBytes(base58.decode(privateKey)); console.log(`\n🔑 Authority: ${authority.address}`); - console.log(`📍 RPC: ${rpcUrl ?? DEVNET_RPC_URL}`); + console.log(`📍 RPC: ${rpcUrl}`); console.log(`💰 Mint: ${mint}\n`); - const resolved = await resolveSwigAccountAddress(connection, authority); - await ensureSwigFunded(connection, authority, resolved.address, mint); + const resolved = await resolveSwigAccountAddress(rpc, authority); + await ensureSwigFunded(rpc, authority, resolved.address, mint); - if (resolved.created) { - persistSwigEnv(join(process.cwd(), ".env"), resolved); - } + persistSwigEnv(join(process.cwd(), ".env"), resolved); + printSwigEnvSummary(resolved); - console.log(JSON.stringify({ ok: true, swigAccountAddress: resolved.address })); + console.log( + JSON.stringify({ + ok: true, + swigAccountAddress: resolved.address, + created: resolved.created, + ...(resolved.swigIdBase58 ? { swigIdBase58: resolved.swigIdBase58 } : {}), + }), + ); } main().catch(error => { diff --git a/e2e/servers/go/catalog.go b/e2e/servers/go/catalog.go index b4d1e122c2..3391e7c378 100644 --- a/e2e/servers/go/catalog.go +++ b/e2e/servers/go/catalog.go @@ -94,9 +94,11 @@ type catalogRouteDefinition struct { Network string `json:"network"` AssetTransferMethod string `json:"assetTransferMethod"` Sdks []string `json:"sdks"` + RequiresEnv string `json:"requiresEnv"` Price catalogPrice `json:"price"` Extensions []string `json:"extensions"` SettlementOverride *SettlementAmount `json:"settlementOverride"` + PaymentFlow string `json:"paymentFlow"` } // SettlementAmount is the partial amount an upto route settles. @@ -119,9 +121,11 @@ type CatalogRoute struct { Scheme string Network string AssetTransferMethod string + RequiresEnv string Price catalogPrice Extensions []string SettlementOverride *SettlementAmount + PaymentFlow string } // ResolvedRoute is a catalog route with env-dependent requirements resolved. @@ -353,15 +357,20 @@ func CatalogRoutes() []CatalogRoute { if excludedSchemes[definition.Scheme] || excludedNetworks[definition.Network] { continue } + if definition.RequiresEnv != "" && os.Getenv(definition.RequiresEnv) == "" { + continue + } routes = append(routes, CatalogRoute{ Path: path, Scheme: definition.Scheme, Network: definition.Network, AssetTransferMethod: definition.AssetTransferMethod, + RequiresEnv: definition.RequiresEnv, Price: definition.Price, Extensions: definition.Extensions, SettlementOverride: definition.SettlementOverride, + PaymentFlow: definition.PaymentFlow, }) } return routes @@ -493,6 +502,26 @@ func resolvePrice(route CatalogRoute, network catalogNetwork, caip2 string) (int return price, nil, nil } +// mergeRouteExtra merges price-derived extra with catalog paymentFlow. +// Authorization is omitted on the wire, matching core applyPaymentFlowWireExtra. +func mergeRouteExtra(priceExtra map[string]interface{}, paymentFlow string) map[string]interface{} { + var wireFlow string + if paymentFlow != "" && paymentFlow != "authorization" { + wireFlow = paymentFlow + } + if wireFlow == "" && len(priceExtra) == 0 { + return nil + } + extra := map[string]interface{}{} + for key, value := range priceExtra { + extra[key] = value + } + if wireFlow != "" { + extra["paymentFlow"] = wireFlow + } + return extra +} + // ResolvedRoutes resolves every catalog route against this process's env. // Routes whose network has no configured payee address are dropped, so the // server only advertises what it can settle. @@ -509,11 +538,12 @@ func ResolvedRoutes() []ResolvedRoute { caip2 := NetworkCaip2(route.Network) - price, extra, err := resolvePrice(route, network, caip2) + price, priceExtra, err := resolvePrice(route, network, caip2) if err != nil { fmt.Printf("❌ %v\n", err) os.Exit(1) } + extra := mergeRouteExtra(priceExtra, route.PaymentFlow) resolved = append(resolved, ResolvedRoute{ Path: route.Path, @@ -561,7 +591,13 @@ func RouteDescription(route ResolvedRoute) string { if len(sponsoring) > 0 { suffix = " with " + strings.Join(sponsoring, " and ") } - return fmt.Sprintf("Protected %s%sendpoint on %s%s", scheme, transfer, label, suffix) + flow := "" + if route.Extra != nil { + if paymentFlow, ok := route.Extra["paymentFlow"].(string); ok && paymentFlow != "" && paymentFlow != "authorization" { + flow = " " + paymentFlow + } + } + return fmt.Sprintf("Protected %s%sendpoint on %s%s%s", scheme, transfer, label, flow, suffix) } // McpToolName converts a catalog path to an MCP tool name: "/exact/evm/eip3009" → "exact_evm_eip3009". diff --git a/e2e/servers/go/config.go b/e2e/servers/go/config.go index 20553af0ce..576d6bfd25 100644 --- a/e2e/servers/go/config.go +++ b/e2e/servers/go/config.go @@ -150,12 +150,9 @@ func SchemeBindings(cfg Config) []SchemeBinding { return exactSVM case "upto": if uptoSVM == nil { - // The authorizer signs settlement vouchers; without it the - // server cannot authorize any channel settlement. authorizerKey := os.Getenv("SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY") if authorizerKey == "" { - fmt.Println("❌ SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY is required for upto on svm") - os.Exit(1) + return nil } authorizer, err := svmsigners.NewReceiverAuthorizerSignerFromPrivateKey(authorizerKey) if err != nil { diff --git a/e2e/servers/python/catalog.py b/e2e/servers/python/catalog.py index 53cc60b609..2c45e52712 100644 --- a/e2e/servers/python/catalog.py +++ b/e2e/servers/python/catalog.py @@ -103,6 +103,7 @@ class CatalogRoute: price: dict[str, Any] extensions: list[str] settlement_override: dict[str, str] | None + payment_flow: str | None @dataclass(frozen=True) @@ -166,6 +167,7 @@ def catalog_routes() -> list[CatalogRoute]: price=definition["price"], extensions=list(definition.get("extensions", [])), settlement_override=definition.get("settlementOverride"), + payment_flow=definition.get("paymentFlow"), ) ) @@ -258,6 +260,23 @@ def _resolve_price( return price, None +def _merge_route_extra( + price_extra: dict[str, str] | None, + payment_flow: str | None, +) -> dict[str, str] | None: + """Merge price-derived extra with catalog paymentFlow. + + Authorization is omitted on the wire, matching core applyPaymentFlowWireExtra. + """ + wire_flow = payment_flow if payment_flow and payment_flow != "authorization" else None + if not wire_flow and not price_extra: + return None + extra = dict(price_extra or {}) + if wire_flow: + extra["paymentFlow"] = wire_flow + return extra + + def resolve_routes(env: Callable[[str], str | None] = os.getenv) -> list[ResolvedRoute]: """Resolve catalog routes for one server process. @@ -274,6 +293,7 @@ def resolve_routes(env: Callable[[str], str | None] = os.getenv) -> list[Resolve caip2 = network_caip2(route.network, env) price, extra = _resolve_price(route, caip2, env) + extra = _merge_route_extra(extra, route.payment_flow) resolved.append( ResolvedRoute( @@ -328,6 +348,7 @@ def route_description( scheme: str, asset_transfer_method: str | None, extensions: list[str] | None, + payment_flow: str | None = None, ) -> str: """Human-readable description for an MCP tool, mirroring the TS catalog helper.""" label = network_id.upper() @@ -339,7 +360,10 @@ def route_description( if ext_id in _GAS_SPONSORING_LABELS ] suffix = f" with {' and '.join(sponsoring)}" if sponsoring else "" - return f"Protected {scheme_prefix}{transfer}endpoint on {label}{suffix}" + flow = "" + if payment_flow and payment_flow != "authorization": + flow = f" {payment_flow}" + return f"Protected {scheme_prefix}{transfer}endpoint on {label}{flow}{suffix}" def route_discovery_output() -> dict[str, Any]: diff --git a/e2e/servers/python/http/fastapi/uv.lock b/e2e/servers/python/http/fastapi/uv.lock index e53b5c8de4..cd4e2b5dc6 100644 --- a/e2e/servers/python/http/fastapi/uv.lock +++ b/e2e/servers/python/http/fastapi/uv.lock @@ -11,6 +11,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + [[package]] name = "abnf" version = "2.7.0" @@ -3050,58 +3054,27 @@ wheels = [ name = "solana" version = "0.38.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "aiolimiter", marker = "python_full_version < '3.11'" }, - { name = "construct-typing", marker = "python_full_version < '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "aiolimiter" }, + { name = "construct-typing" }, + { name = "httpx2", extra = ["http2"] }, + { name = "pydantic" }, + { name = "solders" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/74/9f0621b79059d1e8c010d63514316fb691d94f0c408a215fb71173c5a896/solana-0.38.0.tar.gz", hash = "sha256:ca2c6441483ce5c0ce57d3538ae35cbe04e9c79e6cfc76942799746bfd9ff2d6", size = 498247, upload-time = "2026-06-22T01:42:08.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/1d/4f1d03735b33f32f5223026c45240c61c0730de598f78f1dcbb129389443/solana-0.38.0-py3-none-any.whl", hash = "sha256:396360b01b7ec463c3c92f25450eaf264202fd629f78054bb935ebb0a0410ff5", size = 73328, upload-time = "2026-06-22T01:42:07.509Z" }, ] -[[package]] -name = "solana" -version = "0.40.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "aiolimiter", marker = "python_full_version >= '3.11'" }, - { name = "construct-typing", marker = "python_full_version >= '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/f1728d9f44c479dbbf9d46814511d1a2b46affb314f72bf5810e7b0facd6/solana-0.40.1.tar.gz", hash = "sha256:75fd05aabba2dfeb4f6810e785e4aded3e119daee9cc8752584f244d82ef2c11", size = 474358, upload-time = "2026-07-17T00:05:08.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/af/64ac2e9bc18d4f75e946af41d605213c82c3fa75525837bbe68f68cc7211/solana-0.40.1-py3-none-any.whl", hash = "sha256:25a093bf2b5dfa439a99390088878feca0694703b8dd2dc6786ed84c72da1c20", size = 48305, upload-time = "2026-07-17T00:05:06.828Z" }, -] - [[package]] name = "solders" version = "0.27.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "jsonalias", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "jsonalias" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } wheels = [ @@ -3115,32 +3088,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, ] -[[package]] -name = "solders" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "jsonalias", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/b5/739f342aecacedf2a55e7baaff10714f1d70b4a4ccf2df4155fbe85a5a38/solders-0.28.0.tar.gz", hash = "sha256:c910d4fcc97bb00ec0146425b73a74e51241865de743be215b6c2fb664040013", size = 174557, upload-time = "2026-07-01T17:29:31.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/35/2ea73d0f6b7701a6747991842f76addb506dcc1cdcd1c310a6543e214d68/solders-0.28.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:07844e22f7a7b8fd698a46f2a09d355d35d9898a1bd86017ba6cb519eb11e4c1", size = 23084694, upload-time = "2026-07-01T17:29:14.522Z" }, - { url = "https://files.pythonhosted.org/packages/57/9e/cfdd9eea38d221fc72844b030ac3bb3e5dc60758aaf023dc77482e76019e/solders-0.28.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015ae6841cea52539521bb76c121df5742a476d1ae3718f1ddcd0d00f015fa59", size = 12056383, upload-time = "2026-07-01T17:29:18.016Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cb/565ac41638d925516cf8898f5fb574f2f9c4110159af5e2da7d4cb163dbd/solders-0.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad8c3f4516cb2c0214763766b10c065b64cdda1efed9440319aceac3cdc2615", size = 12513925, upload-time = "2026-07-01T17:29:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/4f/96/b8a74e55a0c978c776a30decc64266d56525eca89d5460c4965bf1c05735/solders-0.28.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cd3635e478765c279788b5a0875c0be19e72ec5ac37f36659479d02f47287594", size = 12629581, upload-time = "2026-07-01T17:29:23.654Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a7/5a6385537c6ae1071583b3538f1c40a8377d0b814d21505d5ccd535383cb/solders-0.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e21fd158660865f46dd93f0986f0606973f91833c723dc6f42c90e76aeaacf4d", size = 12663803, upload-time = "2026-07-01T17:29:26.594Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/6a6eb6e944c00579555de72a81bfb8191299e05aa2f1fbd28837179e6110/solders-0.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:38eceb69ecb514cb8b5ee9a7a80434f147ab0655094ab2bfc685b31a115acdc4", size = 11152191, upload-time = "2026-07-01T17:29:29.269Z" }, -] - [[package]] name = "starlette" version = "1.6.0" @@ -3563,7 +3510,7 @@ wheels = [ [[package]] name = "x402" -version = "2.19.0" +version = "2.21.0" source = { editable = "../../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3590,10 +3537,8 @@ fastapi = [ { name = "starlette" }, ] svm = [ - { name = "solana", version = "0.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solana", version = "0.40.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "solana" }, + { name = "solders" }, ] tvm = [ { name = "httpx" }, diff --git a/e2e/servers/python/http/flask/uv.lock b/e2e/servers/python/http/flask/uv.lock index 826149e6af..733d730bdf 100644 --- a/e2e/servers/python/http/flask/uv.lock +++ b/e2e/servers/python/http/flask/uv.lock @@ -2505,7 +2505,7 @@ wheels = [ [[package]] name = "x402" -version = "2.18.0" +version = "2.21.0" source = { editable = "../../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, diff --git a/e2e/servers/python/mcp/main.py b/e2e/servers/python/mcp/main.py index bc60add539..c49fc6967b 100644 --- a/e2e/servers/python/mcp/main.py +++ b/e2e/servers/python/mcp/main.py @@ -44,7 +44,7 @@ def main() -> None: # descriptions too even though they never register a tool below. tool_descriptions = { route.path: route_description( - route.network, route.scheme, route.asset_transfer_method, route.extensions + route.network, route.scheme, route.asset_transfer_method, route.extensions, route.payment_flow ) for route in catalog_routes() } diff --git a/e2e/servers/python/mcp/uv.lock b/e2e/servers/python/mcp/uv.lock index 998bde88f9..ea0612547b 100644 --- a/e2e/servers/python/mcp/uv.lock +++ b/e2e/servers/python/mcp/uv.lock @@ -8,6 +8,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + [[package]] name = "abnf" version = "2.7.0" @@ -2472,55 +2476,27 @@ wheels = [ name = "solana" version = "0.38.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "aiolimiter", marker = "python_full_version < '3.11'" }, - { name = "construct-typing", marker = "python_full_version < '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "aiolimiter" }, + { name = "construct-typing" }, + { name = "httpx2", extra = ["http2"] }, + { name = "pydantic" }, + { name = "solders" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/74/9f0621b79059d1e8c010d63514316fb691d94f0c408a215fb71173c5a896/solana-0.38.0.tar.gz", hash = "sha256:ca2c6441483ce5c0ce57d3538ae35cbe04e9c79e6cfc76942799746bfd9ff2d6", size = 498247, upload-time = "2026-06-22T01:42:08.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/1d/4f1d03735b33f32f5223026c45240c61c0730de598f78f1dcbb129389443/solana-0.38.0-py3-none-any.whl", hash = "sha256:396360b01b7ec463c3c92f25450eaf264202fd629f78054bb935ebb0a0410ff5", size = 73328, upload-time = "2026-06-22T01:42:07.509Z" }, ] -[[package]] -name = "solana" -version = "0.40.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "aiolimiter", marker = "python_full_version >= '3.11'" }, - { name = "construct-typing", marker = "python_full_version >= '3.11'" }, - { name = "httpx2", extra = ["http2"], marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/f1728d9f44c479dbbf9d46814511d1a2b46affb314f72bf5810e7b0facd6/solana-0.40.1.tar.gz", hash = "sha256:75fd05aabba2dfeb4f6810e785e4aded3e119daee9cc8752584f244d82ef2c11", size = 474358, upload-time = "2026-07-17T00:05:08.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/af/64ac2e9bc18d4f75e946af41d605213c82c3fa75525837bbe68f68cc7211/solana-0.40.1-py3-none-any.whl", hash = "sha256:25a093bf2b5dfa439a99390088878feca0694703b8dd2dc6786ed84c72da1c20", size = 48305, upload-time = "2026-07-17T00:05:06.828Z" }, -] - [[package]] name = "solders" version = "0.27.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "jsonalias", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "jsonalias" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } wheels = [ @@ -2534,29 +2510,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, ] -[[package]] -name = "solders" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", -] -dependencies = [ - { name = "jsonalias", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/b5/739f342aecacedf2a55e7baaff10714f1d70b4a4ccf2df4155fbe85a5a38/solders-0.28.0.tar.gz", hash = "sha256:c910d4fcc97bb00ec0146425b73a74e51241865de743be215b6c2fb664040013", size = 174557, upload-time = "2026-07-01T17:29:31.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/35/2ea73d0f6b7701a6747991842f76addb506dcc1cdcd1c310a6543e214d68/solders-0.28.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:07844e22f7a7b8fd698a46f2a09d355d35d9898a1bd86017ba6cb519eb11e4c1", size = 23084694, upload-time = "2026-07-01T17:29:14.522Z" }, - { url = "https://files.pythonhosted.org/packages/57/9e/cfdd9eea38d221fc72844b030ac3bb3e5dc60758aaf023dc77482e76019e/solders-0.28.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015ae6841cea52539521bb76c121df5742a476d1ae3718f1ddcd0d00f015fa59", size = 12056383, upload-time = "2026-07-01T17:29:18.016Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cb/565ac41638d925516cf8898f5fb574f2f9c4110159af5e2da7d4cb163dbd/solders-0.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad8c3f4516cb2c0214763766b10c065b64cdda1efed9440319aceac3cdc2615", size = 12513925, upload-time = "2026-07-01T17:29:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/4f/96/b8a74e55a0c978c776a30decc64266d56525eca89d5460c4965bf1c05735/solders-0.28.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cd3635e478765c279788b5a0875c0be19e72ec5ac37f36659479d02f47287594", size = 12629581, upload-time = "2026-07-01T17:29:23.654Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a7/5a6385537c6ae1071583b3538f1c40a8377d0b814d21505d5ccd535383cb/solders-0.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e21fd158660865f46dd93f0986f0606973f91833c723dc6f42c90e76aeaacf4d", size = 12663803, upload-time = "2026-07-01T17:29:26.594Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/6a6eb6e944c00579555de72a81bfb8191299e05aa2f1fbd28837179e6110/solders-0.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:38eceb69ecb514cb8b5ee9a7a80434f147ab0655094ab2bfc685b31a115acdc4", size = 11152191, upload-time = "2026-07-01T17:29:29.269Z" }, -] - [[package]] name = "sse-starlette" version = "3.4.8" @@ -2752,7 +2705,7 @@ wheels = [ [[package]] name = "x402" -version = "2.19.0" +version = "2.21.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -2778,10 +2731,8 @@ mcp = [ { name = "mcp" }, ] svm = [ - { name = "solana", version = "0.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solana", version = "0.40.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "solders", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "solders", version = "0.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "solana" }, + { name = "solders" }, ] tvm = [ { name = "httpx" }, diff --git a/e2e/servers/typescript/catalog.ts b/e2e/servers/typescript/catalog.ts index 1b1287a89a..16d10a1476 100644 --- a/e2e/servers/typescript/catalog.ts +++ b/e2e/servers/typescript/catalog.ts @@ -7,7 +7,7 @@ import { CLOSE_PATH, HEALTH_PATH, - filterRoutes, + availableRoutes, resolvePaymentRoutes, routeFilterFromEnv, sdkRoutesFor, @@ -28,7 +28,7 @@ const routeFilter = routeFilterFromEnv(key => process.env[key]); * configured — those answer 501 via {@link getUnconfiguredResponseForPath}. */ export function catalogRoutes(): SdkRoute[] { - return filterRoutes(sdkRoutesFor(SDK), routeFilter); + return availableRoutes(sdkRoutesFor(SDK), key => process.env[key], routeFilter); } /** Catalog routes with env-dependent payment requirements resolved. */ diff --git a/e2e/servers/typescript/http/next/lib/setup.ts b/e2e/servers/typescript/http/next/lib/setup.ts index 983928c70e..353d7af332 100644 --- a/e2e/servers/typescript/http/next/lib/setup.ts +++ b/e2e/servers/typescript/http/next/lib/setup.ts @@ -6,7 +6,7 @@ import { type RouteConfig, } from "@x402/core/server"; -import { PROTECTED_ROUTE_MESSAGE } from "../../../../../src/mechanisms"; +import { PROTECTED_ROUTE_MESSAGE, nextWithX402HttpPath } from "../../../../../src/mechanisms"; import { buildUnconfiguredFamilyError, loadServerEnv, @@ -76,7 +76,11 @@ export function createWithX402GetHandler(catalogPath: string, server: x402Resour } if (!wrapped) { - wrapped = withX402(buildWithX402Handler(catalogPath, cfg), routeConfig, server); + wrapped = withX402( + buildWithX402Handler(catalogPath, cfg), + { [nextWithX402HttpPath(catalogPath)]: routeConfig }, + server, + ); } return wrapped(req); }; diff --git a/e2e/src/cli/args.ts b/e2e/src/cli/args.ts index 9cbe05b8ca..4c77fb6c66 100644 --- a/e2e/src/cli/args.ts +++ b/e2e/src/cli/args.ts @@ -46,6 +46,12 @@ export function parseArgs(): ParsedArgs { arg.startsWith('--versions=') || arg.startsWith('--families=') || arg.startsWith('--schemes=') || + arg.startsWith('--sdk=') || + arg.startsWith('--sdks=') || + arg.startsWith('--paymentflow=') || + arg.startsWith('--paymentFlow=') || + arg.startsWith('--assetTransferMethod=') || + arg.startsWith('--asset-transfer-method=') || arg.startsWith('--endpoints=') ); @@ -98,6 +104,13 @@ export function parseArgs(): ParsedArgs { const versions = parseListArg(args, '--versions')?.map(v => parseInt(v)); const families = parseListArg(args, '--families'); const schemes = parseListArg(args, '--schemes'); + const sdks = parseListArg(args, '--sdk', '--sdks'); + const paymentFlows = parseListArg(args, '--paymentflow', '--paymentFlow'); + const assetTransferMethods = parseListArg( + args, + '--assetTransferMethod', + '--asset-transfer-method', + ); const endpoints = parseListArg(args, '--endpoints'); return { @@ -114,6 +127,9 @@ export function parseArgs(): ParsedArgs { versions, protocolFamilies: families, schemes, + sdks, + paymentFlows, + assetTransferMethods, endpoints, }, showHelp: false, @@ -126,10 +142,10 @@ export function parseArgs(): ParsedArgs { }; } -function parseListArg(args: string[], argName: string): string[] | undefined { - const arg = args.find(a => a.startsWith(`${argName}=`)); +function parseListArg(args: string[], ...argNames: string[]): string[] | undefined { + const arg = args.find(a => argNames.some(name => a.startsWith(`${name}=`))); if (!arg) return undefined; - const value = arg.split('=')[1]; + const value = arg.split('=').slice(1).join('='); return value.split(',').map(v => v.trim()).filter(v => v.length > 0); } @@ -154,6 +170,9 @@ export function printHelp(): void { console.log(' --versions= Comma-separated version numbers (e.g., 1,2)'); console.log(' --families= Comma-separated protocol families (e.g., evm,svm,hedera,tvm)'); console.log(' --schemes= Payment schemes: exact, upto, batch-settlement'); + console.log(' --sdk= SDK languages: ts, go, python (aliases: typescript, py)'); + console.log(' --paymentflow= Payment flows: authorization, upfront, escrow'); + console.log(' --assetTransferMethod= Asset transfer methods: eip3009, permit2, sequence, ticketSequence'); console.log(' --endpoints= Comma-separated endpoint paths or regex patterns (auto-anchored)'); console.log(''); console.log('Options:'); @@ -178,6 +197,8 @@ export function printHelp(): void { console.log(" pnpm test --testnet --endpoints='/protected' # Exact path match"); console.log(" pnpm test --testnet --endpoints='/protected-permit2.*' # Regex: all permit2 routes"); console.log(' pnpm test --testnet --schemes=exact,batch-settlement # Only those payment schemes'); + console.log(' pnpm test --testnet --sdk=ts --paymentflow=upfront # TypeScript SDK, upfront routes'); + console.log(' pnpm test --testnet --assetTransferMethod=eip3009 # EIP-3009 transfer method only'); console.log(' pnpm test --testnet --min --parallel -v # Parallel mode'); console.log(' pnpm test --testnet --min --parallel --concurrency=2 -v # Limited concurrency'); console.log(''); diff --git a/e2e/src/cli/filters.ts b/e2e/src/cli/filters.ts index d4cdfd5a53..bae22dd372 100644 --- a/e2e/src/cli/filters.ts +++ b/e2e/src/cli/filters.ts @@ -1,8 +1,30 @@ -import { TestScenario, endpointPaymentScheme } from '../types'; +import { + TestScenario, + endpointPaymentScheme, + endpointPaymentFlow, + endpointAssetTransferMethod, + type PaymentFlow, + type AssetTransferMethod, +} from '../types'; /** x402 payment scheme for filtering. */ export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement'; +/** CLI aliases for catalog SDK ids (`typescript` / `python` / `go`). */ +const SDK_ALIASES: Record = { + ts: 'typescript', + typescript: 'typescript', + py: 'python', + python: 'python', + go: 'go', +}; + +/** Normalize `--sdk` values (`ts` → `typescript`). Unknown ids pass through lowercased. */ +export function normalizeSdkId(value: string): string { + const key = value.trim().toLowerCase(); + return SDK_ALIASES[key] ?? key; +} + /** * Classify a scenario's payment scheme for filtering (`endpoint.scheme`, default `exact`). */ @@ -16,6 +38,31 @@ export function getUniquePaymentSchemes(scenarios: TestScenario[]): PaymentSchem return Array.from(set).sort(); } +export function getScenarioPaymentFlow(scenario: TestScenario): PaymentFlow { + return endpointPaymentFlow(scenario.endpoint); +} + +export function getUniquePaymentFlows(scenarios: TestScenario[]): PaymentFlow[] { + const set = new Set(); + scenarios.forEach(s => set.add(getScenarioPaymentFlow(s))); + return Array.from(set).sort(); +} + +export function getScenarioAssetTransferMethod( + scenario: TestScenario, +): AssetTransferMethod | undefined { + return endpointAssetTransferMethod(scenario.endpoint); +} + +export function getUniqueAssetTransferMethods(scenarios: TestScenario[]): AssetTransferMethod[] { + const set = new Set(); + scenarios.forEach(s => { + const method = getScenarioAssetTransferMethod(s); + if (method) set.add(method); + }); + return Array.from(set).sort(); +} + export interface TestFilters { transports?: string[]; facilitators?: string[]; @@ -25,6 +72,9 @@ export interface TestFilters { versions?: number[]; protocolFamilies?: string[]; schemes?: string[]; + sdks?: string[]; + paymentFlows?: string[]; + assetTransferMethods?: string[]; endpoints?: string[]; // Regex patterns to filter by endpoint path } @@ -91,6 +141,36 @@ export function filterScenarios( } } + // SDK language filter — client, server, and facilitator must all be in the list + if (filters.sdks && filters.sdks.length > 0) { + const allowed = new Set(filters.sdks.map(normalizeSdkId)); + const languages = [ + scenario.client.config.language, + scenario.server.config.language, + scenario.facilitator?.config.language, + ].filter((lang): lang is string => Boolean(lang)); + if (languages.some(lang => !allowed.has(lang))) { + return false; + } + } + + // Payment flow filter (omitted catalog value = authorization) + if (filters.paymentFlows && filters.paymentFlows.length > 0) { + const normalized = filters.paymentFlows.map(s => s.trim().toLowerCase()); + if (!normalized.includes(getScenarioPaymentFlow(scenario))) { + return false; + } + } + + // Asset transfer method filter + if (filters.assetTransferMethods && filters.assetTransferMethods.length > 0) { + const normalized = filters.assetTransferMethods.map(s => s.trim().toLowerCase()); + const method = getScenarioAssetTransferMethod(scenario); + if (!method || !normalized.includes(method.toLowerCase())) { + return false; + } + } + // Endpoint filter — each entry is treated as a regex pattern. // Patterns are auto-anchored (^...$) so that "/protected" matches only // that exact path. To match a prefix, use "/protected.*"; for a substring diff --git a/e2e/src/cli/interactive.ts b/e2e/src/cli/interactive.ts index 27c77dbe67..926963a869 100644 --- a/e2e/src/cli/interactive.ts +++ b/e2e/src/cli/interactive.ts @@ -7,8 +7,13 @@ import { getUniqueProtocolFamilies, getUniquePaymentSchemes, getScenarioPaymentScheme, + getUniquePaymentFlows, + getScenarioPaymentFlow, + getUniqueAssetTransferMethods, + getScenarioAssetTransferMethod, PaymentSchemeKind, } from './filters'; +import type { PaymentFlow, AssetTransferMethod } from '../types'; import { log } from '../logger'; import { NetworkMode, getNetworkModeDescription } from '../networks/networks'; @@ -325,7 +330,79 @@ export async function runInteractiveMode( selectedSchemes = availableSchemes; } - // Question 9: Endpoint filter (optional free-text, comma-separated regex patterns) + // Question 9 (CONDITIONAL): Payment flow — authorization vs upfront vs escrow + const scenariosForFlow = filterScenarios(scenariosForScheme, { + schemes: selectedSchemes, + }); + const availableFlows = getUniquePaymentFlows(scenariosForFlow); + let selectedPaymentFlows: string[] | undefined; + + if (availableFlows.length > 1) { + const flowChoices = availableFlows.map((flow: PaymentFlow) => { + const count = scenariosForFlow.filter(s => getScenarioPaymentFlow(s) === flow).length; + return { + title: `${flow} (${count} scenarios)`, + value: flow, + selected: true, + }; + }); + + const flowsResponse = await prompts({ + type: 'multiselect', + name: 'paymentFlows', + message: 'Select payment flows', + choices: flowChoices, + min: 1, + hint: 'authorization = verify → resource → settle; upfront / escrow = pay before resource', + instructions: false, + }); + + if (!flowsResponse.paymentFlows || flowsResponse.paymentFlows.length === 0) { + return null; + } + + selectedPaymentFlows = flowsResponse.paymentFlows; + } else if (availableFlows.length === 1) { + selectedPaymentFlows = availableFlows; + } + + // Question 10 (CONDITIONAL): Asset transfer method + const scenariosForAtm = filterScenarios(scenariosForFlow, { + paymentFlows: selectedPaymentFlows, + }); + const availableAtms = getUniqueAssetTransferMethods(scenariosForAtm); + let selectedAssetTransferMethods: string[] | undefined; + + if (availableAtms.length > 1) { + const atmChoices = availableAtms.map((method: AssetTransferMethod) => { + const count = scenariosForAtm.filter(s => getScenarioAssetTransferMethod(s) === method).length; + return { + title: `${method} (${count} scenarios)`, + value: method, + selected: true, + }; + }); + + const atmResponse = await prompts({ + type: 'multiselect', + name: 'assetTransferMethods', + message: 'Select asset transfer methods', + choices: atmChoices, + min: 1, + hint: 'eip3009, permit2 (EVM); sequence, ticketSequence (XRPL)', + instructions: false, + }); + + if (!atmResponse.assetTransferMethods || atmResponse.assetTransferMethods.length === 0) { + return null; + } + + selectedAssetTransferMethods = atmResponse.assetTransferMethods; + } else if (availableAtms.length === 1) { + selectedAssetTransferMethods = availableAtms; + } + + // Question 11: Endpoint filter (optional free-text, comma-separated regex patterns) const endpointsResponse = await prompts({ type: 'text', name: 'endpoints', @@ -342,7 +419,7 @@ export async function runInteractiveMode( ? (endpointsResponse.endpoints as string).split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) : undefined; - // Question 10: Select network mode (testnet/mainnet) - LAST question + // Question 12: Select network mode (testnet/mainnet) - LAST question // Skip if preselected via CLI flag let networkMode: NetworkMode; @@ -392,6 +469,8 @@ export async function runInteractiveMode( versions: selectedVersions, protocolFamilies: selectedFamilies, schemes: selectedSchemes, + paymentFlows: selectedPaymentFlows, + assetTransferMethods: selectedAssetTransferMethods, endpoints: selectedEndpoints, networkMode, }; diff --git a/e2e/src/discovery.ts b/e2e/src/discovery.ts index bcc66109ee..826bb25518 100644 --- a/e2e/src/discovery.ts +++ b/e2e/src/discovery.ts @@ -4,7 +4,7 @@ import { GenericServerProxy } from './servers/generic-server'; import { GenericClientProxy } from './clients/generic-client'; import { GenericFacilitatorProxy } from './facilitators/generic-facilitator'; import { discoverComponentLocations, loadComponentConfig } from './component'; -import { schemesForSdkNetwork } from './mechanisms'; +import { schemesForComponent } from './mechanisms'; import { verboseLog, errorLog } from './logger'; import { TestConfig, @@ -238,7 +238,11 @@ export class TestDiscovery { verboseLog(` ⚠️ Skipping ${client.name}: No language specified`); continue; } - const clientSchemesForFamily = schemesForSdkNetwork(clientLanguage, endpointProtocolFamily); + const clientSchemesForFamily = schemesForComponent( + clientLanguage, + endpointProtocolFamily, + client.config.schemes, + ); if (!clientSchemesForFamily.includes(endpointScheme)) { verboseLog(` ⚠️ Skipping ${client.name} ↔ ${server.name} ${endpoint.path}: Payment scheme mismatch (client supports [${clientSchemesForFamily.join(', ')}] on ${endpointProtocolFamily}, endpoint requires ${endpointScheme})`); continue; @@ -262,7 +266,15 @@ export class TestDiscovery { const supportsVersion = f.config.x402Versions?.includes(serverVersion); const facilLanguage = f.config.language; if (!facilLanguage) return false; - const facilSchemesForFamily = schemesForSdkNetwork(facilLanguage, endpointProtocolFamily); + const clientFacilitators = client.config.facilitators; + if (clientFacilitators && !clientFacilitators.includes(f.name)) { + return false; + } + const facilSchemesForFamily = schemesForComponent( + facilLanguage, + endpointProtocolFamily, + f.config.schemes, + ); if (!facilSchemesForFamily.includes(endpointScheme)) return false; if (endpointProtocolFamily === 'evm') { const endpointAtm = endpointAssetTransferMethod(endpoint)!; diff --git a/e2e/src/mechanisms.ts b/e2e/src/mechanisms.ts index 2b87dd4955..b8bb45664c 100644 --- a/e2e/src/mechanisms.ts +++ b/e2e/src/mechanisms.ts @@ -16,6 +16,7 @@ import { readFileSync, readdirSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import type { PaymentFlowName } from '@x402/core/types'; /** Keep local types here to avoid circular imports with types.ts / networks.ts. */ export type SdkId = 'typescript' | 'python' | 'go'; @@ -25,6 +26,8 @@ export type CatalogNetworkId = string; type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; type AssetTransferMethod = 'eip3009' | 'permit2' | 'sequence' | 'ticketSequence'; +/** Payment ordering on the accept; mirrors core {@link PaymentFlowName}. */ +export type PaymentFlow = PaymentFlowName; export type NetworkMode = 'testnet' | 'mainnet'; /** Per-key env declaration in mechanisms_*.json. */ @@ -110,6 +113,14 @@ export type RouteDefinition = { extensions?: string[]; /** Settle less than the maximum (upto scheme). */ settlementOverride?: { amount: string }; + /** + * Payment flow for this route. Omitted or `"authorization"` is the default + * (verify → resource → settle). Non-authorization values are merged into + * route `extra.paymentFlow`, matching core wire rules. + */ + paymentFlow?: PaymentFlow; + /** Omit this route unless the named env var is set (optional add-on routes). */ + requiresEnv?: string; }; /** Fixed success body for every paid route (`timestamp` is added by the server). */ @@ -157,6 +168,7 @@ type EndpointLike = { protocolFamily?: CatalogNetworkId; scheme?: PaymentScheme; assetTransferMethod?: AssetTransferMethod; + paymentFlow?: PaymentFlow; schemeOptions?: Record; extensions?: string[]; health?: boolean; @@ -289,6 +301,23 @@ export function schemesForSdkNetwork(sdk: string, network: CatalogNetworkId): Pa return Array.from(schemes); } +/** + * Schemes a harness component supports on one network. When `declaredSchemes` + * is set (client/facilitator test.config), intersect with the SDK catalog so + * custom surfaces like svm-smart-wallet do not inherit every SDK route scheme. + */ +export function schemesForComponent( + sdk: string, + network: CatalogNetworkId, + declaredSchemes: PaymentScheme[] | undefined, +): PaymentScheme[] { + const sdkSchemes = schemesForSdkNetwork(sdk, network); + if (!declaredSchemes?.length) { + return sdkSchemes; + } + return declaredSchemes.filter(scheme => sdkSchemes.includes(scheme)); +} + /** EVM asset transfer methods declared on an SDK's routes. */ export function evmAssetTransferMethodsForSdk(sdk: string): AssetTransferMethod[] | undefined { const methods = new Set(); @@ -319,7 +348,9 @@ function routeDescription(route: SdkRoute): string { .map(id => GAS_SPONSORING_LABELS[id]) .filter(Boolean); const suffix = sponsoring.length > 0 ? ` with ${sponsoring.join(' and ')}` : ''; - return `Protected ${scheme}${transfer}endpoint on ${label}${suffix}`; + const flow = + route.paymentFlow && route.paymentFlow !== 'authorization' ? ` ${route.paymentFlow}` : ''; + return `Protected ${scheme}${transfer}endpoint on ${label}${flow}${suffix}`; } /** MCP tool name for a catalog path: `/exact/evm/eip3009` → `exact_evm_eip3009`. */ @@ -340,6 +371,7 @@ export function sdkRouteToEndpoint(route: SdkRoute, transport: RouteTransport = protocolFamily: route.network, scheme: route.scheme, assetTransferMethod: route.assetTransferMethod, + paymentFlow: route.paymentFlow, schemeOptions: route.schemeOptions, extensions: route.extensions, }; @@ -353,6 +385,7 @@ export function sdkRouteToEndpoint(route: SdkRoute, transport: RouteTransport = protocolFamily: route.network, scheme: route.scheme, assetTransferMethod: route.assetTransferMethod, + paymentFlow: route.paymentFlow, schemeOptions: route.schemeOptions, extensions: route.extensions, }; @@ -426,6 +459,13 @@ export type RouteFilter = { excludeNetworks?: string[]; }; +export function routeEnvSatisfied(route: SdkRoute, env: EnvLookup): boolean { + if (!route.requiresEnv) { + return true; + } + return Boolean(env(route.requiresEnv)?.trim()); +} + export function filterRoutes(routes: SdkRoute[], filter?: RouteFilter): SdkRoute[] { if (!filter?.excludeSchemes?.length && !filter?.excludeNetworks?.length) { return routes; @@ -435,8 +475,20 @@ export function filterRoutes(routes: SdkRoute[], filter?: RouteFilter): SdkRoute return routes.filter(route => !schemes.has(route.scheme) && !networks.has(route.network)); } -export function sdkRoutesToEndpoints(sdk: string, filter?: RouteFilter): EndpointLike[] { - return filterRoutes(sdkRoutesFor(sdk), filter).map(route => sdkRouteToEndpoint(route)); +export function availableRoutes( + routes: SdkRoute[], + env: EnvLookup, + filter?: RouteFilter, +): SdkRoute[] { + return filterRoutes(routes, filter).filter(route => routeEnvSatisfied(route, env)); +} + +export function sdkRoutesToEndpoints( + sdk: string, + env: EnvLookup = key => process.env[key], + filter?: RouteFilter, +): EndpointLike[] { + return availableRoutes(sdkRoutesFor(sdk), env, filter).map(route => sdkRouteToEndpoint(route)); } /** @@ -462,12 +514,16 @@ export function extensionsForSdk(sdk: string, role: ConfigRole): string[] { } /** - * Env keys the harness assigns itself per run (not operator-supplied), so the - * facilitator env preflight check must never flag them as "missing". `PORT` - * is the only one: it's allocated by {@link createPortAllocator} and injected - * by `GenericFacilitatorProxy.start`, well after the preflight check runs. + * Env keys the harness assigns per run (not operator-supplied), so the + * facilitator/client env preflight check must never flag them as "missing". + * - `PORT`: allocated by {@link createPortAllocator}, injected at facilitator start. + * - `RESOURCE_SERVER_URL` / `ENDPOINT_PATH`: set per scenario by {@link GenericClientProxy}. */ -export const FACILITATOR_ENV_PREFLIGHT_ALLOWLIST: ReadonlySet = new Set(['PORT']); +export const FACILITATOR_ENV_PREFLIGHT_ALLOWLIST: ReadonlySet = new Set([ + 'PORT', + 'RESOURCE_SERVER_URL', + 'ENDPOINT_PATH', +]); /** * Which roles read an env key: catalog declaration, else SERVER_/CLIENT_/ @@ -581,7 +637,7 @@ export function enrichConfigFromMechanisms( excludeSchemes: config.excludeSchemes as string[] | undefined, excludeNetworks: config.excludeNetworks as string[] | undefined, }; - const routes = filterRoutes(sdkRoutesFor(sdk), filter); + const routes = availableRoutes(sdkRoutesFor(sdk), key => process.env[key], filter); const fromCatalog = NETWORK_IDS.filter(id => routes.some(route => route.network === id)); const protocolFamilies = @@ -788,6 +844,21 @@ function serverAddressEnvKey(network: CatalogNetworkId): string { return `SERVER_${network.toUpperCase()}_ADDRESS`; } +/** Merge price-derived `extra` with catalog `paymentFlow` (authorization omitted on wire). */ +function mergeRouteExtra( + priceExtra: Record | undefined, + paymentFlow?: PaymentFlow, +): Record | undefined { + const wireFlow = paymentFlow && paymentFlow !== 'authorization' ? paymentFlow : undefined; + if (!wireFlow && !priceExtra) { + return undefined; + } + return { + ...priceExtra, + ...(wireFlow ? { paymentFlow: wireFlow } : {}), + }; +} + function resolvePrice( route: SdkRoute, caip2: string, @@ -853,13 +924,14 @@ export function resolvePaymentRoutes( ): ResolvedRoute[] { const resolved: ResolvedRoute[] = []; - for (const route of filterRoutes(sdkRoutesFor(sdk), filter)) { + for (const route of availableRoutes(sdkRoutesFor(sdk), env, filter)) { const def = getNetworkDefinition(route.network); const payTo = env(serverAddressEnvKey(route.network)); if (!payTo) continue; const caip2 = env(derivedNetworkKey(route.network)) ?? def.networks.testnet.caip2; - const { price, extra } = resolvePrice(route, caip2, env); + const { price, extra: priceExtra } = resolvePrice(route, caip2, env); + const extra = mergeRouteExtra(priceExtra, route.paymentFlow); resolved.push({ path: route.path, diff --git a/e2e/src/types.ts b/e2e/src/types.ts index 83b25ce411..7e2038bc9c 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -4,6 +4,8 @@ export type { ProtocolFamily } from './networks/networks'; export type Transport = 'http' | 'mcp'; export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; export type AssetTransferMethod = 'eip3009' | 'permit2' | 'sequence' | 'ticketSequence'; +/** Payment ordering on the accept. Omitted on an endpoint means `authorization`. */ +export type PaymentFlow = 'authorization' | 'upfront' | 'escrow'; /** * Resolved asset transfer method for an endpoint. @@ -31,6 +33,14 @@ export function endpointPaymentScheme(endpoint: TestEndpoint): PaymentScheme { return endpoint.scheme ?? 'exact'; } +/** + * Resolved payment flow for an endpoint. + * Defaults to `authorization` when omitted. + */ +export function endpointPaymentFlow(endpoint: TestEndpoint): PaymentFlow { + return endpoint.paymentFlow ?? 'authorization'; +} + /** Harness knobs for exact / upto endpoints (Permit2 settle paths). */ export interface Permit2SchemeOptions { permit2Direct?: boolean; @@ -99,6 +109,8 @@ export interface TestEndpoint { protocolFamily?: ProtocolFamily; scheme?: PaymentScheme; assetTransferMethod?: AssetTransferMethod; + /** Omitted or `authorization` is the default (verify → resource → settle). */ + paymentFlow?: PaymentFlow; schemeOptions?: SchemeOptions; extensions?: string[]; /** For MCP tools: the tool name used in tools/call. Defaults to path if not specified. */ @@ -128,6 +140,7 @@ export interface TestConfig { evm?: { assetTransferMethods?: AssetTransferMethod[]; }; + facilitators?: string[]; endpoints?: TestEndpoint[]; supportedMethods?: string[]; capabilities?: { diff --git a/e2e/test.ts b/e2e/test.ts index 1f6823f7ad..b02885bca2 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -86,7 +86,6 @@ async function approvePermit2Approval(evm: NetworkConfig, tokenAddress?: string) const child = spawn('tsx', args, { cwd: process.cwd(), stdio: 'pipe', - shell: true, env: { ...process.env, EVM_NETWORK: evm.caip2, EVM_RPC_URL: evm.rpcUrl }, }); @@ -137,7 +136,6 @@ async function revokePermit2Approval(evm: NetworkConfig, tokenAddress?: string): const child = spawn('tsx', args, { cwd: process.cwd(), stdio: 'pipe', - shell: true, env: { ...process.env, EVM_NETWORK: evm.caip2, EVM_RPC_URL: evm.rpcUrl }, }); @@ -172,18 +170,67 @@ async function revokePermit2Approval(evm: NetworkConfig, tokenAddress?: string): }); } +/** True when a client declares Swig setup env (svm-smart-wallet overlay). */ +function clientRequiresSwigSetup(client: { config: { environment?: { required?: string[] } } }): boolean { + return (client.config.environment?.required ?? []).includes('SWIG_ACCOUNT_ADDRESS'); +} + +type SwigSetupResult = { + ok?: boolean; + swigAccountAddress?: string; + created?: boolean; + swigIdBase58?: string; +}; + +function applySwigSetupResult(result: SwigSetupResult, logCreation: boolean): boolean { + if (!result.ok || !result.swigAccountAddress) { + return false; + } + + process.env.SWIG_ACCOUNT_ADDRESS = result.swigAccountAddress; + if (result.swigIdBase58) { + process.env.SWIG_ID_BASE58 = result.swigIdBase58; + } + + if (logCreation && result.created) { + log(` ✅ New Swig smart wallet: SWIG_ACCOUNT_ADDRESS=${result.swigAccountAddress}`); + if (result.swigIdBase58) { + log(` SWIG_ID_BASE58=${result.swigIdBase58}`); + } + } else { + verboseLog(` ✅ Swig setup complete: ${result.swigAccountAddress}`); + } + + return true; +} + +function parseSwigSetupOutput(stdout: string): SwigSetupResult | undefined { + const lines = stdout.trim().split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const parsed = JSON.parse(lines[i]!) as SwigSetupResult; + if (parsed.ok && parsed.swigAccountAddress) { + return parsed; + } + } catch { + // not JSON — keep scanning + } + } + return undefined; +} + /** * Prepare Swig smart-wallet state for svm-smart-wallet e2e client tests. - * Called before each endpoint; creates/funds via scripts/swig-setup.ts when balance is low. + * Creates/funds via scripts/swig-setup.ts when SWIG_ACCOUNT_ADDRESS is unset or + * USDC balance is low; reuses SWIG_ACCOUNT_ADDRESS from env when already set. */ -async function setupSwigWallet(svmRpcUrl: string): Promise { +async function setupSwigWallet(svmRpcUrl: string, logCreation = false): Promise { return new Promise((resolve) => { verboseLog(' 🔧 Running Swig wallet setup...'); const child = spawn('tsx', ['scripts/swig-setup.ts'], { cwd: process.cwd(), stdio: 'pipe', - shell: true, env: { ...process.env, SVM_RPC_URL: svmRpcUrl, @@ -196,7 +243,16 @@ async function setupSwigWallet(svmRpcUrl: string): Promise { child.stdout?.on('data', (data) => { const text = data.toString(); stdout += text; - verboseLog(text.trim()); + if (logCreation) { + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed) { + log(trimmed); + } + } + } else { + verboseLog(text.trim()); + } }); child.stderr?.on('data', (data) => { @@ -214,19 +270,10 @@ async function setupSwigWallet(svmRpcUrl: string): Promise { return; } - const lines = stdout.trim().split('\n'); - for (let i = lines.length - 1; i >= 0; i--) { - try { - const parsed = JSON.parse(lines[i]!) as { ok?: boolean; swigAccountAddress?: string }; - if (parsed.ok && parsed.swigAccountAddress) { - process.env.SWIG_ACCOUNT_ADDRESS = parsed.swigAccountAddress; - verboseLog(` ✅ Swig setup complete: ${parsed.swigAccountAddress}`); - resolve(true); - return; - } - } catch { - // not JSON — keep scanning - } + const parsed = parseSwigSetupOutput(stdout); + if (parsed && applySwigSetupResult(parsed, logCreation)) { + resolve(true); + return; } if (process.env.SWIG_ACCOUNT_ADDRESS) { @@ -983,60 +1030,71 @@ async function runTest() { } } - const hasSwigSmartWalletScenarios = filteredScenarios.some( - s => s.client.name === 'typescript/http/svm-smart-wallet', - ); + const hasSwigSmartWalletScenarios = filteredScenarios.some(s => clientRequiresSwigSetup(s.client)); if (hasSwigSmartWalletScenarios) { - log('🔧 Swig smart-wallet scenarios detected — swig-setup runs before each endpoint when balance is low'); + log('🔧 Swig smart-wallet scenarios detected — swig-setup creates/funds when needed (see e2e/.env)'); log(''); } - // Collect unique facilitators and servers + // Collect unique facilitators, servers, and clients const uniqueFacilitators = new Map(); const uniqueServers = new Map(); + const uniqueClients = new Map(); filteredScenarios.forEach(scenario => { if (scenario.facilitator) { uniqueFacilitators.set(scenario.facilitator.name, scenario.facilitator); } uniqueServers.set(scenario.server.name, scenario.server); + uniqueClients.set(scenario.client.name, scenario.client); }); - // Validate environment variables for all selected facilitators - log('\n🔍 Validating facilitator environment variables...\n'); - const missingEnvVars: { facilitatorName: string; missingVars: string[] }[] = []; + // Validate facilitator and client env against catalog-declared requirements. + log('\n🔍 Validating facilitator and client environment variables...\n'); + const missingEnvVars: { componentName: string; missingVars: string[] }[] = []; - for (const [facilitatorName, facilitator] of uniqueFacilitators) { - const requiredVars = facilitator.config.environment?.required || []; - const missing: string[] = []; + const componentsToValidate: Map[] = [uniqueFacilitators, uniqueClients]; - for (const envVar of requiredVars) { - // Skip env keys the harness assigns itself (e.g. PORT), never operator-supplied - if (FACILITATOR_ENV_PREFLIGHT_ALLOWLIST.has(envVar)) { - continue; - } - // Skip credentials for families not in this run (catalog marks all wallet - // keys required per family; only selected families need them present). - const family = protocolFamilyForCredentialKey(envVar); - if (family && !selectedProtocolFamilies.has(family)) { - continue; - } + for (const components of componentsToValidate) { + for (const [componentName, component] of components) { + const requiredVars = component.config.environment?.required || []; + const missing: string[] = []; - if (!process.env[envVar]) { - missing.push(envVar); + for (const envVar of requiredVars) { + // Skip env keys the harness assigns itself (e.g. PORT), never operator-supplied + if (FACILITATOR_ENV_PREFLIGHT_ALLOWLIST.has(envVar)) { + continue; + } + // Swig smart-wallet: swig-setup creates/persists these before each scenario. + if ( + clientRequiresSwigSetup(component) && + (envVar === 'SWIG_ACCOUNT_ADDRESS' || envVar === 'SWIG_ID_BASE58') + ) { + continue; + } + // Skip credentials for families not in this run (catalog marks all wallet + // keys required per family; only selected families need them present). + const family = protocolFamilyForCredentialKey(envVar); + if (family && !selectedProtocolFamilies.has(family)) { + continue; + } + + if (!process.env[envVar]) { + missing.push(envVar); + } } - } - if (missing.length > 0) { - missingEnvVars.push({ facilitatorName, missingVars: missing }); + if (missing.length > 0) { + missingEnvVars.push({ componentName, missingVars: missing }); + } } } if (missingEnvVars.length > 0) { - errorLog('❌ Missing required environment variables for selected facilitators:\n'); - for (const { facilitatorName, missingVars } of missingEnvVars) { - errorLog(` ${facilitatorName}:`); + errorLog('❌ Missing required environment variables for selected facilitators/clients:\n'); + for (const { componentName, missingVars } of missingEnvVars) { + errorLog(` ${componentName}:`); missingVars.forEach(varName => errorLog(` - ${varName}`)); } errorLog('\n💡 Please set the required environment variables and try again.\n'); @@ -1045,6 +1103,16 @@ async function runTest() { log(' ✅ All required environment variables are present\n'); + if (hasSwigSmartWalletScenarios) { + log('🔧 Bootstrapping Swig smart wallet (create if unset, fund if low)...\n'); + const swigReady = await setupSwigWallet(networks.svm.rpcUrl, true); + if (!swigReady) { + errorLog('❌ Swig wallet bootstrap failed — fund CLIENT_SVM_PRIVATE_KEY with devnet SOL and USDC'); + process.exit(1); + } + log(''); + } + // Clean up any processes on test ports from previous runs try { execSync('pnpm clean:ports', { cwd: process.cwd(), stdio: 'pipe' }); @@ -1472,9 +1540,8 @@ async function runTest() { mockFacilitatorUrl, }; - const started = await startServer(serverProxy, serverConfig, { transport: server.config.transport }); - if (!started) { - cLog.log(`❌ Failed to start server ${serverName}`); + const serverStartFailures = (error: string) => { + cLog.log(`❌ Failed to start server ${serverName}${error ? `: ${error}` : ''}`); return scenarios.map(scenario => ({ testNumber: nextTestNumber(), client: scenario.client.name, @@ -1485,6 +1552,16 @@ async function runTest() { passed: false, error: 'Server failed to start', })); + }; + + let started = false; + try { + started = await startServer(serverProxy, serverConfig, { transport: server.config.transport }); + } catch (error) { + return serverStartFailures(error instanceof Error ? error.message : String(error)); + } + if (!started) { + return serverStartFailures(''); } cLog.log(` ✅ Server ${serverName} ready`); @@ -1512,8 +1589,11 @@ async function runTest() { error, }); - if (scenario.client.name === 'typescript/http/svm-smart-wallet') { - await setupSwigWallet(networks.svm.rpcUrl); + if (clientRequiresSwigSetup(scenario.client)) { + const swigReady = await setupSwigWallet(networks.svm.rpcUrl); + if (!swigReady) { + return setupFailure('Swig wallet setup failed'); + } } if (scenario.endpoint.schemeOptions?.permit2Direct === true) { diff --git a/examples/go/clients/http/README.md b/examples/go/clients/http/README.md index b32fdb16cf..48d8e789aa 100644 --- a/examples/go/clients/http/README.md +++ b/examples/go/clients/http/README.md @@ -68,8 +68,10 @@ signer, err := evmsigners.NewClientSignerFromPrivateKey(os.Getenv("EVM_PRIVATE_K // Configure client with builder pattern. // Newx402Client() enables default spend controls: recognized pegged assets only, capped at $1 USD. +// Registers exact, upto, and auth-capture EVM schemes (plus SVM when configured). client := x402.Newx402Client(). - Register("eip155:*", evm.NewExactEvmScheme(signer)) + Register("eip155:*", evm.NewExactEvmScheme(signer)). + Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(signer)) // Optional: raise the cap or opt into non-default tokens // client.SetSpendControls(x402.SpendControls{MaxAmountPerPayment: "$5"}) diff --git a/examples/go/clients/http/builder_pattern.go b/examples/go/clients/http/builder_pattern.go index f72f3bbf2d..b71dafbef6 100644 --- a/examples/go/clients/http/builder_pattern.go +++ b/examples/go/clients/http/builder_pattern.go @@ -2,6 +2,7 @@ package main import ( x402 "github.com/x402-foundation/x402/go/v2" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" exactevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/client" exactsvm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/client" @@ -38,6 +39,7 @@ func createBuilderPatternClient(evmPrivateKey, svmPrivateKey, evmRpcURL string) // Register EVM schemes for all EVM networks client.Register("eip155:*", exactevm.NewExactEvmScheme(evmSigner, rpcConfig)) client.Register("eip155:*", uptoevm.NewUptoEvmScheme(evmSigner, rpcConfig)) + client.Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(evmSigner)) // You can also register specific networks for fine-grained control // For example, use a different signer for Ethereum mainnet: diff --git a/examples/go/clients/http/mechanism_helper_registration.go b/examples/go/clients/http/mechanism_helper_registration.go index 88acf9bcb3..bb1f9ccd98 100644 --- a/examples/go/clients/http/mechanism_helper_registration.go +++ b/examples/go/clients/http/mechanism_helper_registration.go @@ -2,6 +2,7 @@ package main import ( x402 "github.com/x402-foundation/x402/go/v2" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" exactevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/client" exactsvm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/client" @@ -39,6 +40,7 @@ func createMechanismHelperRegistrationClient(evmPrivateKey, svmPrivateKey, evmRp // Register EVM schemes for all EVM networks using wildcard client.Register("eip155:*", exactevm.NewExactEvmScheme(evmSigner, rpcConfig)) client.Register("eip155:*", uptoevm.NewUptoEvmScheme(evmSigner, rpcConfig)) + client.Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(evmSigner)) // Register SVM scheme if key is provided if svmPrivateKey != "" { diff --git a/examples/go/facilitator/advanced/signer.go b/examples/go/facilitator/advanced/signer.go index 9872a6f93e..4dc6df1897 100644 --- a/examples/go/facilitator/advanced/signer.go +++ b/examples/go/facilitator/advanced/signer.go @@ -528,7 +528,7 @@ func (s *facilitatorSvmSigner) SimulateTransaction(ctx context.Context, tx *sola } opts := rpc.SimulateTransactionOpts{ - SigVerify: true, + SigVerify: false, ReplaceRecentBlockhash: false, Commitment: svmmech.DefaultCommitment, } @@ -607,12 +607,48 @@ func (s *facilitatorSvmSigner) ConfirmTransaction(ctx context.Context, signature } // Wait before retrying - time.Sleep(svmmech.ConfirmRetryDelay) + delay := svmmech.ConfirmRetryDelay + if attempt < svmmech.ConfirmInitialAttempts { + delay = svmmech.ConfirmInitialRetryDelay + } + time.Sleep(delay) } return fmt.Errorf("transaction confirmation timed out after %d attempts", svmmech.MaxConfirmAttempts) } +func (s *facilitatorSvmSigner) SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, network string) ([]rpc.InnerInstruction, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.SimulateWithInnerInstructions(ctx, rpcClient, tx) +} + +func (s *facilitatorSvmSigner) GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, network string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, nil, err + } + return svmmech.ConfirmedTransactionInnerInstructions(ctx, rpcClient, signature) +} + +func (s *facilitatorSvmSigner) GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, network string) (uint64, bool, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, false, err + } + return svmmech.TokenAccountBalance(ctx, rpcClient, tokenAccount) +} + +func (s *facilitatorSvmSigner) FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, network string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.AddressLookupTables(ctx, rpcClient, tables) +} + func (s *facilitatorSvmSigner) GetAddresses(ctx context.Context, network string) []solana.PublicKey { return []solana.PublicKey{s.privateKey.PublicKey()} } diff --git a/examples/go/facilitator/basic/go.mod b/examples/go/facilitator/basic/go.mod index 27df7365cd..ad4c6d4b0d 100644 --- a/examples/go/facilitator/basic/go.mod +++ b/examples/go/facilitator/basic/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.1 replace github.com/x402-foundation/x402/go/v2 => ../../../../go require ( - github.com/ethereum/go-ethereum v1.16.7 + github.com/ethereum/go-ethereum v1.17.2 github.com/gagliardetto/solana-go v1.14.0 github.com/gin-gonic/gin v1.11.0 github.com/joho/godotenv v1.5.1 @@ -24,20 +24,21 @@ require ( github.com/blendle/zapdriver v1.3.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/consensys/gnark-crypto v0.18.0 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/fatih/color v1.16.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gagliardetto/binary v0.8.0 // indirect github.com/gagliardetto/treeout v0.1.4 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -48,7 +49,7 @@ require ( github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.16.0 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect @@ -64,12 +65,16 @@ require ( github.com/quic-go/quic-go v0.55.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect - github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect + github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.mongodb.org/mongo-driver v1.12.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/ratelimit v0.2.0 // indirect @@ -79,10 +84,10 @@ require ( golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/examples/go/facilitator/basic/main.go b/examples/go/facilitator/basic/main.go index 9491b5589e..bfd27df658 100644 --- a/examples/go/facilitator/basic/main.go +++ b/examples/go/facilitator/basic/main.go @@ -69,7 +69,7 @@ func main() { if svmSigner != nil { settlementCache := svmmech.NewSettlementCache() - facilitator.Register([]x402.Network{svmNetwork}, svm.NewExactSvmScheme(svmSigner, settlementCache)) + facilitator.Register([]x402.Network{svmNetwork}, svm.NewExactSvmScheme(svmSigner, &svm.Config{SettlementCache: settlementCache})) facilitator.RegisterV1([]x402.Network{"solana-devnet"}, svmv1.NewExactSvmSchemeV1(svmSigner, settlementCache)) } @@ -96,6 +96,11 @@ func main() { // Verify endpoint - verifies payment signatures r.POST("/verify", func(c *gin.Context) { + endpointT0 := time.Now() + defer func() { + fmt.Printf("/verify completed in %.3fs\n", time.Since(endpointT0).Seconds()) + }() + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() @@ -129,6 +134,11 @@ func main() { // Settle endpoint - settles payments on-chain r.POST("/settle", func(c *gin.Context) { + endpointT0 := time.Now() + defer func() { + fmt.Printf("/settle completed in %.3fs\n", time.Since(endpointT0).Seconds()) + }() + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) defer cancel() diff --git a/examples/go/facilitator/basic/signer.go b/examples/go/facilitator/basic/signer.go index 4eb2d0f6bb..615b5a0b3f 100644 --- a/examples/go/facilitator/basic/signer.go +++ b/examples/go/facilitator/basic/signer.go @@ -481,7 +481,7 @@ func (s *facilitatorSvmSigner) SimulateTransaction(ctx context.Context, tx *sola } opts := rpc.SimulateTransactionOpts{ - SigVerify: true, + SigVerify: false, ReplaceRecentBlockhash: false, Commitment: svmmech.DefaultCommitment, } @@ -560,12 +560,48 @@ func (s *facilitatorSvmSigner) ConfirmTransaction(ctx context.Context, signature } // Wait before retrying - time.Sleep(svmmech.ConfirmRetryDelay) + delay := svmmech.ConfirmRetryDelay + if attempt < svmmech.ConfirmInitialAttempts { + delay = svmmech.ConfirmInitialRetryDelay + } + time.Sleep(delay) } return fmt.Errorf("transaction confirmation timed out after %d attempts", svmmech.MaxConfirmAttempts) } +func (s *facilitatorSvmSigner) SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, network string) ([]rpc.InnerInstruction, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.SimulateWithInnerInstructions(ctx, rpcClient, tx) +} + +func (s *facilitatorSvmSigner) GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, network string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, nil, err + } + return svmmech.ConfirmedTransactionInnerInstructions(ctx, rpcClient, signature) +} + +func (s *facilitatorSvmSigner) GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, network string) (uint64, bool, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, false, err + } + return svmmech.TokenAccountBalance(ctx, rpcClient, tokenAccount) +} + +func (s *facilitatorSvmSigner) FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, network string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svmmech.AddressLookupTables(ctx, rpcClient, tables) +} + func (s *facilitatorSvmSigner) GetAddresses(ctx context.Context, network string) []solana.PublicKey { return []solana.PublicKey{s.privateKey.PublicKey()} } diff --git a/examples/go/facilitator/upto/main.go b/examples/go/facilitator/upto/main.go index ddc08be5cd..2ce6a7e425 100644 --- a/examples/go/facilitator/upto/main.go +++ b/examples/go/facilitator/upto/main.go @@ -50,7 +50,6 @@ func main() { // one in production so cleanup survives restarts and works across replicas. maxChannelLifetimeSecs := envInt("MAX_CHANNEL_LIFETIME_SECS", uptosvm.DefaultMaxChannelLifetimeSecs) scheme := uptosvm.NewUptoSvmScheme(signer, &uptosvm.Config{ - RPCURL: rpcURL, MaxChannelLifetimeSecs: &maxChannelLifetimeSecs, }) diff --git a/examples/go/facilitator/upto/signer.go b/examples/go/facilitator/upto/signer.go index 685b5a041a..bb68137703 100644 --- a/examples/go/facilitator/upto/signer.go +++ b/examples/go/facilitator/upto/signer.go @@ -73,7 +73,7 @@ func (s *facilitatorSvmSigner) SimulateTransaction( ctx context.Context, tx *solana.Transaction, _ string, ) error { result, err := s.rpcClient.SimulateTransactionWithOpts(ctx, tx, &rpc.SimulateTransactionOpts{ - SigVerify: true, + SigVerify: false, Commitment: svmmech.DefaultCommitment, }) if err != nil { @@ -122,7 +122,73 @@ func (s *facilitatorSvmSigner) ConfirmTransaction( } } } - time.Sleep(svmmech.ConfirmRetryDelay) + delay := svmmech.ConfirmRetryDelay + if attempt < svmmech.ConfirmInitialAttempts { + delay = svmmech.ConfirmInitialRetryDelay + } + time.Sleep(delay) } return fmt.Errorf("transaction %s was not confirmed in time", signature) } + +func (s *facilitatorSvmSigner) GetAccountInfo( + ctx context.Context, + account solana.PublicKey, + _ string, + opts *rpc.GetAccountInfoOpts, +) (*rpc.GetAccountInfoResult, error) { + return s.rpcClient.GetAccountInfoWithOpts(ctx, account, opts) +} + +func (s *facilitatorSvmSigner) GetLatestBlockhash(ctx context.Context, _ string) (solana.Hash, uint64, error) { + latest, err := s.rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return solana.Hash{}, 0, err + } + return latest.Value.Blockhash, latest.Value.LastValidBlockHeight, nil +} + +func (s *facilitatorSvmSigner) GetSlot(ctx context.Context, _ string, commitment rpc.CommitmentType) (uint64, error) { + return s.rpcClient.GetSlot(ctx, commitment) +} + +func (s *facilitatorSvmSigner) SimulateTransactionWithOpts( + ctx context.Context, + tx *solana.Transaction, + _ string, + opts *rpc.SimulateTransactionOpts, +) error { + result, err := s.rpcClient.SimulateTransactionWithOpts(ctx, tx, opts) + if err != nil { + return fmt.Errorf("simulation failed: %w", err) + } + if result != nil && result.Value != nil && result.Value.Err != nil { + return fmt.Errorf("simulation failed: %v", result.Value.Err) + } + return nil +} + +func (s *facilitatorSvmSigner) GetProgramAccounts( + ctx context.Context, + _ string, + programID solana.PublicKey, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + return s.rpcClient.GetProgramAccountsWithOpts(ctx, programID, opts) +} + +func (s *facilitatorSvmSigner) SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, _ string) ([]rpc.InnerInstruction, error) { + return svmmech.SimulateWithInnerInstructions(ctx, s.rpcClient, tx) +} + +func (s *facilitatorSvmSigner) GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, _ string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + return svmmech.ConfirmedTransactionInnerInstructions(ctx, s.rpcClient, signature) +} + +func (s *facilitatorSvmSigner) GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, _ string) (uint64, bool, error) { + return svmmech.TokenAccountBalance(ctx, s.rpcClient, tokenAccount) +} + +func (s *facilitatorSvmSigner) FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, _ string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + return svmmech.AddressLookupTables(ctx, s.rpcClient, tables) +} diff --git a/examples/go/servers/upfront/.env-example b/examples/go/servers/upfront/.env-example new file mode 100644 index 0000000000..24cb2b4c63 --- /dev/null +++ b/examples/go/servers/upfront/.env-example @@ -0,0 +1,3 @@ +EVM_PAYEE_ADDRESS= +SVM_PAYEE_ADDRESS= +FACILITATOR_URL=https://x402.org/facilitator diff --git a/examples/go/servers/upfront/README.md b/examples/go/servers/upfront/README.md new file mode 100644 index 0000000000..e5ee06fa84 --- /dev/null +++ b/examples/go/servers/upfront/README.md @@ -0,0 +1,76 @@ +# Upfront Example Server (Go) + +Gin server demonstrating the `upfront` payment flow on the `exact` scheme: the facilitator settles onchain **before** the route handler runs. + +Use this when the resource needs on-chain finality before execution — for example, long-running handlers on Solana, where the default `authorization` flow requires the handler to finish before the signed transaction's blockhash expires (~60–90 seconds). + +```go +accepts = append(accepts, x402http.PaymentOption{ + Scheme: "exact", + Price: "$0.001", + Network: evmNetwork, + PayTo: evmAddress, + Extra: map[string]interface{}{ + "paymentFlow": "upfront", + }, +}) +``` + +## Trust model + +Under `upfront`, the payer commits funds before your handler runs. If the handler fails after settlement, the client has already paid; the middleware still echoes the settlement receipt in `PAYMENT-RESPONSE`. + +## Prerequisites + +- Go 1.24+ +- Valid EVM and/or SVM addresses for receiving payments +- URL of a facilitator supporting the desired payment network — see the [facilitator list](https://www.x402.org/ecosystem?category=facilitators) + +## Setup + +1. Copy `.env-example` to `.env`: + +```bash +cp .env-example .env +``` + +and fill required environment variables: + +- `FACILITATOR_URL` — facilitator endpoint URL +- `EVM_PAYEE_ADDRESS` — Ethereum address to receive payments (optional if SVM is set) +- `SVM_PAYEE_ADDRESS` — Solana address to receive payments (optional if EVM is set) + +2. Run the server: + +```bash +go run . +``` + +On a paid request you should see settlement logged before the handler: + +``` +[upfront] settled (before-handler) tx=0x... +[upfront] handler running (settlement already completed) +``` + +The example uses `OnAfterSettle` on the resource server so settlement is logged at the correct phase. Gin's `SettlementHandler` runs after the handler and would show the wrong order for `upfront`. + +## Testing the Server + +You can test the server using one of the example clients: + +### Using the HTTP Client + +```bash +cd ../../clients/http +# Ensure .env is setup +go run . +``` + +The 402 response includes `extra.paymentFlow: "upfront"`. + +## Example Endpoint + +The server includes a single example endpoint at `/weather` that requires a payment of 0.001 USDC on Base Sepolia or Solana Devnet to access. Settlement happens before the handler returns the weather report. + +Pair with [`facilitator/basic/`](../../facilitator/basic/) for local development. diff --git a/examples/go/servers/upfront/go.mod b/examples/go/servers/upfront/go.mod new file mode 100644 index 0000000000..ae6155c412 --- /dev/null +++ b/examples/go/servers/upfront/go.mod @@ -0,0 +1,96 @@ +module github.com/x402-foundation/x402/examples/go/servers/upfront + +go 1.24.0 + +toolchain go1.24.1 + +replace github.com/x402-foundation/x402/go/v2 => ../../../../go + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/joho/godotenv v1.5.1 + github.com/x402-foundation/x402/go/v2 v2.14.0 +) + +require ( + filippo.io/edwards25519 v1.0.0-rc.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/ethereum/go-ethereum v1.17.2 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/solana-go v1.14.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.4 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.8 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.mongodb.org/mongo-driver v1.12.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/ratelimit v0.2.0 // indirect + go.uber.org/zap v1.21.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/examples/go/servers/upfront/go.sum b/examples/go/servers/upfront/go.sum new file mode 100644 index 0000000000..1749ec9b76 --- /dev/null +++ b/examples/go/servers/upfront/go.sum @@ -0,0 +1,389 @@ +filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= +filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= +github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE= +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw= +github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY= +github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw= +github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws= +go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA= +go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go/servers/upfront/main.go b/examples/go/servers/upfront/main.go new file mode 100644 index 0000000000..6c329cf402 --- /dev/null +++ b/examples/go/servers/upfront/main.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + ginfw "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" + ginmw "github.com/x402-foundation/x402/go/v2/http/gin" + evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server" + svm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/server" +) + +const DefaultPort = "4021" + +func main() { + godotenv.Load() + + evmAddress := os.Getenv("EVM_PAYEE_ADDRESS") + svmAddress := os.Getenv("SVM_PAYEE_ADDRESS") + if evmAddress == "" && svmAddress == "" { + fmt.Println("One of EVM_PAYEE_ADDRESS or SVM_PAYEE_ADDRESS is required") + os.Exit(1) + } + + facilitatorURL := os.Getenv("FACILITATOR_URL") + if facilitatorURL == "" { + fmt.Println("FACILITATOR_URL environment variable is required") + os.Exit(1) + } + + evmNetwork := x402.Network("eip155:84532") + svmNetwork := x402.Network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + + fmt.Printf("Starting Gin x402 upfront server...\n") + fmt.Printf(" Facilitator: %s\n", facilitatorURL) + + var accepts x402http.PaymentOptions + var schemes []ginmw.SchemeConfig + + if evmAddress != "" { + fmt.Printf(" EVM Payee address: %s (%s)\n", evmAddress, evmNetwork) + accepts = append(accepts, x402http.PaymentOption{ + Scheme: "exact", + Price: "$0.001", + Network: evmNetwork, + PayTo: evmAddress, + Extra: map[string]interface{}{ + "paymentFlow": "upfront", + }, + }) + schemes = append(schemes, ginmw.SchemeConfig{ + Network: evmNetwork, + Server: evm.NewExactEvmScheme(), + }) + } + + if svmAddress != "" { + fmt.Printf(" SVM Payee address: %s (%s)\n", svmAddress, svmNetwork) + accepts = append(accepts, x402http.PaymentOption{ + Scheme: "exact", + Price: "$0.001", + Network: svmNetwork, + PayTo: svmAddress, + Extra: map[string]interface{}{ + "paymentFlow": "upfront", + }, + }) + schemes = append(schemes, ginmw.SchemeConfig{ + Network: svmNetwork, + Server: svm.NewExactSvmScheme(), + }) + } + + r := ginfw.Default() + + facilitatorClient := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ + URL: facilitatorURL, + }) + + resourceServer := x402.Newx402ResourceServer( + x402.WithFacilitatorClient(facilitatorClient), + ). + OnAfterSettle(func(ctx x402.SettleResultContext) error { + fmt.Printf("[upfront] settled (%s) tx=%s\n", ctx.Phase, ctx.Result.Transaction) + return nil + }) + for _, scheme := range schemes { + resourceServer.Register(scheme.Network, scheme.Server) + } + + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: accepts, + Description: "Weather data", + MimeType: "application/json", + }, + } + + r.Use(ginmw.PaymentMiddleware(routes, resourceServer, ginmw.WithTimeout(30*time.Second))) + + r.GET("/weather", func(c *ginfw.Context) { + fmt.Println("[upfront] handler running (settlement already completed)") + c.JSON(http.StatusOK, ginfw.H{ + "report": ginfw.H{ + "weather": "sunny", + "temperature": 70, + }, + }) + }) + + r.GET("/health", func(c *ginfw.Context) { + c.JSON(http.StatusOK, ginfw.H{ + "status": "ok", + "version": "2.0.0", + }) + }) + + fmt.Printf(" Server listening on http://localhost:%s\n\n", DefaultPort) + fmt.Println(" GET /weather - exact scheme with upfront payment flow") + + if err := r.Run(":" + DefaultPort); err != nil { + fmt.Printf("Error starting server: %v\n", err) + os.Exit(1) + } +} diff --git a/examples/python/README.md b/examples/python/README.md index 57494daaa4..66a67447e2 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -8,7 +8,7 @@ Examples for the x402 Python SDK. cd clients/httpx cp .env-local .env # Edit .env with your EVM_PRIVATE_KEY and/or SVM_PRIVATE_KEY -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` @@ -23,6 +23,7 @@ uv run python main.py ### Servers - **[servers/fastapi/](./servers/fastapi/)** - FastAPI server with payment middleware - **[servers/flask/](./servers/flask/)** - Flask server with payment middleware +- **[servers/upfront/](./servers/upfront/)** - `exact` scheme with `upfront` payment flow (settle before handler) - **[servers/custom/](./servers/custom/)** - Manual payment handling - **[servers/advanced/](./servers/advanced/)** - Dynamic pricing, hooks, and more diff --git a/examples/python/clients/README.md b/examples/python/clients/README.md index 2b1ed7bc8e..5a7816dbda 100644 --- a/examples/python/clients/README.md +++ b/examples/python/clients/README.md @@ -21,7 +21,7 @@ This directory contains examples demonstrating how to use the x402 v2 SDK with d cd httpx cp .env-local .env # Edit .env with your EVM_PRIVATE_KEY and/or SVM_PRIVATE_KEY -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` @@ -31,7 +31,7 @@ uv run python main.py cd requests cp .env-local .env # Edit .env with your EVM_PRIVATE_KEY and/or SVM_PRIVATE_KEY -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` diff --git a/examples/python/clients/advanced/README.md b/examples/python/clients/advanced/README.md index 421cb47bb8..8c15f7b989 100644 --- a/examples/python/clients/advanced/README.md +++ b/examples/python/clients/advanced/README.md @@ -20,7 +20,7 @@ To fund your TVM payer wallet, request testnet TON from [@testgiver_ton_bot](htt ```bash cd examples/python/clients/advanced - uv sync + uv sync --reinstall-package x402 ``` 2. **Configure environment:** @@ -34,7 +34,7 @@ To fund your TVM payer wallet, request testnet TON from [@testgiver_ton_bot](htt ```bash cd examples/python/servers/fastapi - uv sync && uv run uvicorn main:app --port 4021 + uv sync --reinstall-package x402 && uv run uvicorn main:app --port 4021 ``` ## Running Examples diff --git a/examples/python/clients/batch-settlement/README.md b/examples/python/clients/batch-settlement/README.md index 8c0009ffa0..d7800cbf0f 100644 --- a/examples/python/clients/batch-settlement/README.md +++ b/examples/python/clients/batch-settlement/README.md @@ -9,7 +9,7 @@ at the end to claw back any unused channel balance. ## Setup ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Environment diff --git a/examples/python/clients/builder-code/README.md b/examples/python/clients/builder-code/README.md index 6006c444ae..02d3584946 100644 --- a/examples/python/clients/builder-code/README.md +++ b/examples/python/clients/builder-code/README.md @@ -30,7 +30,7 @@ async with x402HttpxClient(client) as http: 1. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 2. Copy `.env-local` to `.env` and set required variables: @@ -56,12 +56,12 @@ Optional: # Terminal 1: facilitator cd ../../facilitator/builder-code cp .env-local .env -uv sync && uv run python main.py +uv sync --reinstall-package x402 && uv run python main.py # Terminal 2: server cd ../../servers/builder-code cp .env-local .env -uv sync && uv run python main.py +uv sync --reinstall-package x402 && uv run python main.py ``` 4. Run the client: diff --git a/examples/python/clients/custom/README.md b/examples/python/clients/custom/README.md index 8b3e952648..f6b60376d9 100644 --- a/examples/python/clients/custom/README.md +++ b/examples/python/clients/custom/README.md @@ -62,7 +62,7 @@ Use this approach when you need: 3. Install dependencies: ```bash - uv sync + uv sync --reinstall-package x402 ``` ## Running diff --git a/examples/python/clients/httpx/uv.lock b/examples/python/clients/httpx/uv.lock index 04a7779a29..dc53818cc1 100644 --- a/examples/python/clients/httpx/uv.lock +++ b/examples/python/clients/httpx/uv.lock @@ -1748,7 +1748,7 @@ wheels = [ [[package]] name = "x402" -version = "2.19.0" +version = "2.21.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, diff --git a/examples/python/clients/mcp-chatbot/README.md b/examples/python/clients/mcp-chatbot/README.md index 87eb0aade5..285716d9a7 100644 --- a/examples/python/clients/mcp-chatbot/README.md +++ b/examples/python/clients/mcp-chatbot/README.md @@ -37,7 +37,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Running diff --git a/examples/python/clients/mcp/README.md b/examples/python/clients/mcp/README.md index ff6dcfb46b..feb03432b5 100644 --- a/examples/python/clients/mcp/README.md +++ b/examples/python/clients/mcp/README.md @@ -25,7 +25,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Running diff --git a/examples/python/clients/payment-identifier/README.md b/examples/python/clients/payment-identifier/README.md index f8f1e4ab9d..f3d453dddd 100644 --- a/examples/python/clients/payment-identifier/README.md +++ b/examples/python/clients/payment-identifier/README.md @@ -51,7 +51,7 @@ async with x402HttpxClient(client) as http: 1. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 2. Copy `.env-local` to `.env` and add your private key: diff --git a/examples/python/clients/sign-in-with-x/README.md b/examples/python/clients/sign-in-with-x/README.md index 9c3df23ecd..958c20ce65 100644 --- a/examples/python/clients/sign-in-with-x/README.md +++ b/examples/python/clients/sign-in-with-x/README.md @@ -82,7 +82,7 @@ uv sync --reinstall-package x402 ```bash cd ../../servers/sign-in-with-x -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` diff --git a/examples/python/facilitator/advanced/README.md b/examples/python/facilitator/advanced/README.md index fcf3730f4e..7d424f4197 100644 --- a/examples/python/facilitator/advanced/README.md +++ b/examples/python/facilitator/advanced/README.md @@ -39,7 +39,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 4. Run the server: diff --git a/examples/python/facilitator/advanced/bazaar.py b/examples/python/facilitator/advanced/bazaar.py index 0a14d740e1..806ae4582a 100644 --- a/examples/python/facilitator/advanced/bazaar.py +++ b/examples/python/facilitator/advanced/bazaar.py @@ -17,6 +17,7 @@ from solders.keypair import Keypair from x402 import x402Facilitator +from x402.http.extension_responses import EXTENSION_RESPONSES_HEADER from x402.extensions.bazaar import ( DiscoveryResource, extract_discovery_info, @@ -30,7 +31,7 @@ load_dotenv() # Configuration -PORT = int(os.environ.get("PORT", "4022")) +PORT = int(os.environ.get("PORT") or "4022") # Configuration - optional per network evm_private_key = os.environ.get("EVM_PRIVATE_KEY") @@ -103,11 +104,10 @@ def search( bazaar_catalog = BazaarCatalog() -EXTENSION_RESPONSES_HEADER = "EXTENSION-RESPONSES" - def _set_extension_responses_header(response: Response) -> None: - """Attach an example bazaar extension response header for client readback.""" + """Attach bazaar extension outcomes on the facilitator sidechannel. + """ extension_responses = {"bazaar": {"status": "success"}} encoded = base64.b64encode(json.dumps(extension_responses).encode("utf-8")).decode("ascii") response.headers[EXTENSION_RESPONSES_HEADER] = encoded diff --git a/examples/python/facilitator/advanced/uv.lock b/examples/python/facilitator/advanced/uv.lock index 5fe4421980..91d61e6396 100644 --- a/examples/python/facilitator/advanced/uv.lock +++ b/examples/python/facilitator/advanced/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.10" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[[package]] +name = "abnf" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/55/5514e564539e23a4948795695d5f25c184f0e02ac18695db1c8d5e6f3d05/abnf-2.9.0.tar.gz", hash = "sha256:45af3643da2281e34b03fba467bdeb09797f5552d140907a578c2d8ec10fcdd5", size = 1088572, upload-time = "2026-08-27T11:12:45.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/4c/cb77419f4dd5e6086550a05f4bce9eb521c80a2b877215065331c2b476b7/abnf-2.9.0-py3-none-any.whl", hash = "sha256:8592e174c05cc6010f5250f2292b832993f92e2ae136d7618368ee89d649435e", size = 78100, upload-time = "2026-08-27T11:12:44.185Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -2913,6 +2926,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "signinwithethereum" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "abnf" }, + { name = "eth-account" }, + { name = "eth-utils" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "web3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/9c/5bca1085418726e08c333b4b97891c2d03f12b99973418acf7357dd76a54/signinwithethereum-5.0.1.tar.gz", hash = "sha256:9661ac2020cb659cc7c3fc1f9b8a9ff59f2018e259f7a4e1ae641f126a475fa3", size = 165980, upload-time = "2026-04-24T12:16:09.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/22/ac218e503a9634757c09ab66e50a6dc3c9bfaaa877163a744516d33c85ce/signinwithethereum-5.0.1-py3-none-any.whl", hash = "sha256:53551a1073209f4f011589a957efb8603650af7be24433f966bf897405c0ebd3", size = 19342, upload-time = "2026-04-24T12:16:08.072Z" }, +] + [[package]] name = "solana" version = "0.36.11" @@ -3349,7 +3380,7 @@ wheels = [ [[package]] name = "x402" -version = "2.5.0" +version = "2.21.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3366,7 +3397,10 @@ evm = [ { name = "web3" }, ] extensions = [ + { name = "idna" }, { name = "jsonschema" }, + { name = "pynacl" }, + { name = "signinwithethereum" }, ] fastapi = [ { name = "fastapi", extra = ["standard"] }, @@ -3393,14 +3427,17 @@ requires-dist = [ { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, + { name = "idna", marker = "extra == 'extensions'", specifier = ">=3.4" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'extensions'", specifier = ">=1.5.0" }, { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, + { name = "signinwithethereum", marker = "extra == 'extensions'", specifier = ">=5.0.1" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, @@ -3423,6 +3460,7 @@ dev = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = ">=3.4" }, { name = "jsonschema", specifier = ">=4.0.0" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, @@ -3434,6 +3472,7 @@ dev = [ { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, + { name = "signinwithethereum", specifier = "==5.0.1" }, { name = "solana", specifier = ">=0.36.0" }, { name = "solders", specifier = ">=0.27.0" }, { name = "starlette", specifier = ">=0.27.0" }, diff --git a/examples/python/facilitator/basic/README.md b/examples/python/facilitator/basic/README.md index 86c77412f9..42b76a4bb2 100644 --- a/examples/python/facilitator/basic/README.md +++ b/examples/python/facilitator/basic/README.md @@ -29,7 +29,7 @@ and fill required environment variables: 2. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 3. Run the server: diff --git a/examples/python/facilitator/batch-settlement/README.md b/examples/python/facilitator/batch-settlement/README.md index 3398e5207e..9fb5f9437b 100644 --- a/examples/python/facilitator/batch-settlement/README.md +++ b/examples/python/facilitator/batch-settlement/README.md @@ -7,7 +7,7 @@ client examples in this repo. ## Setup ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Environment diff --git a/examples/python/facilitator/builder-code/README.md b/examples/python/facilitator/builder-code/README.md index 6482762d3a..3f49ce1125 100644 --- a/examples/python/facilitator/builder-code/README.md +++ b/examples/python/facilitator/builder-code/README.md @@ -28,7 +28,7 @@ and fill required environment variables: 2. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 3. Run the server: diff --git a/examples/python/servers/advanced/README.md b/examples/python/servers/advanced/README.md index 2a0f4afe2f..67f5d5cf54 100644 --- a/examples/python/servers/advanced/README.md +++ b/examples/python/servers/advanced/README.md @@ -64,7 +64,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 4. Run the server: diff --git a/examples/python/servers/advanced/bazaar.py b/examples/python/servers/advanced/bazaar.py index f85a71fea7..dc7d2f29b1 100644 --- a/examples/python/servers/advanced/bazaar.py +++ b/examples/python/servers/advanced/bazaar.py @@ -1,5 +1,7 @@ """Bazaar discovery extension example.""" +import json +import logging import os from dotenv import load_dotenv @@ -16,10 +18,13 @@ from x402.http.types import RouteConfig from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.schemas import Network +from x402.schemas.hooks import SettleResultContext, VerifyResultContext from x402.server import x402ResourceServer load_dotenv() +logging.basicConfig(level=logging.INFO) + # Config EVM_ADDRESS = os.getenv("EVM_ADDRESS") EVM_NETWORK: Network = "eip155:84532" # Base Sepolia @@ -45,6 +50,34 @@ class WeatherResponse(BaseModel): server.register(EVM_NETWORK, ExactEvmServerScheme()) server.register_extension(bazaar_resource_server_extension) + +def _log_extension_responses(phase: str, ctx: VerifyResultContext | SettleResultContext) -> None: + """Process facilitator extension sidechannel metadata.""" + extension_responses = ctx.extension_responses + if not extension_responses: + print(f"\n=== {phase}: no facilitator extension responses ===") + return + + print(f"\n=== {phase}: facilitator extension responses (sidechannel) ===") + print(json.dumps(extension_responses, indent=2)) + + bazaar = extension_responses.get("bazaar") + if not isinstance(bazaar, dict): + return + + status = bazaar.get("status") + if status == "success": + print(" ✅ Bazaar cataloging confirmed by facilitator") + elif status == "processing": + print(" ⏳ Bazaar cataloging in progress") + elif status == "rejected": + reason = bazaar.get("rejectedReason", "unknown") + print(f" ⚠️ Bazaar rejected: {reason}") + + +server.on_after_verify(lambda ctx: _log_extension_responses("After verify", ctx)) +server.on_after_settle(lambda ctx: _log_extension_responses("After settle", ctx)) + routes = { "GET /weather": RouteConfig( accepts=[ diff --git a/examples/python/servers/advanced/uv.lock b/examples/python/servers/advanced/uv.lock index b829668a35..eec005f663 100644 --- a/examples/python/servers/advanced/uv.lock +++ b/examples/python/servers/advanced/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.10" +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[[package]] +name = "abnf" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/55/5514e564539e23a4948795695d5f25c184f0e02ac18695db1c8d5e6f3d05/abnf-2.9.0.tar.gz", hash = "sha256:45af3643da2281e34b03fba467bdeb09797f5552d140907a578c2d8ec10fcdd5", size = 1088572, upload-time = "2026-08-27T11:12:45.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/4c/cb77419f4dd5e6086550a05f4bce9eb521c80a2b877215065331c2b476b7/abnf-2.9.0-py3-none-any.whl", hash = "sha256:8592e174c05cc6010f5250f2292b832993f92e2ae136d7618368ee89d649435e", size = 78100, upload-time = "2026-08-27T11:12:44.185Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -2913,6 +2926,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "signinwithethereum" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "abnf" }, + { name = "eth-account" }, + { name = "eth-utils" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "web3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/9c/5bca1085418726e08c333b4b97891c2d03f12b99973418acf7357dd76a54/signinwithethereum-5.0.1.tar.gz", hash = "sha256:9661ac2020cb659cc7c3fc1f9b8a9ff59f2018e259f7a4e1ae641f126a475fa3", size = 165980, upload-time = "2026-04-24T12:16:09.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/22/ac218e503a9634757c09ab66e50a6dc3c9bfaaa877163a744516d33c85ce/signinwithethereum-5.0.1-py3-none-any.whl", hash = "sha256:53551a1073209f4f011589a957efb8603650af7be24433f966bf897405c0ebd3", size = 19342, upload-time = "2026-04-24T12:16:08.072Z" }, +] + [[package]] name = "solana" version = "0.36.11" @@ -3349,7 +3380,7 @@ wheels = [ [[package]] name = "x402" -version = "2.5.0" +version = "2.21.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3366,7 +3397,10 @@ evm = [ { name = "web3" }, ] extensions = [ + { name = "idna" }, { name = "jsonschema" }, + { name = "pynacl" }, + { name = "signinwithethereum" }, ] fastapi = [ { name = "fastapi", extra = ["standard"] }, @@ -3393,14 +3427,17 @@ requires-dist = [ { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, + { name = "idna", marker = "extra == 'extensions'", specifier = ">=3.4" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'extensions'", specifier = ">=1.5.0" }, { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, + { name = "signinwithethereum", marker = "extra == 'extensions'", specifier = ">=5.0.1" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, @@ -3423,6 +3460,7 @@ dev = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = ">=3.4" }, { name = "jsonschema", specifier = ">=4.0.0" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, @@ -3434,6 +3472,7 @@ dev = [ { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, + { name = "signinwithethereum", specifier = "==5.0.1" }, { name = "solana", specifier = ">=0.36.0" }, { name = "solders", specifier = ">=0.27.0" }, { name = "starlette", specifier = ">=0.27.0" }, diff --git a/examples/python/servers/batch-settlement/README.md b/examples/python/servers/batch-settlement/README.md index f3949a2f90..0e63b75fde 100644 --- a/examples/python/servers/batch-settlement/README.md +++ b/examples/python/servers/batch-settlement/README.md @@ -15,7 +15,7 @@ For Flask or other sync servers, use `HTTPFacilitatorClientSync` with ## Setup ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Environment diff --git a/examples/python/servers/bazaar/README.md b/examples/python/servers/bazaar/README.md index c4b1d68335..1abf566b23 100644 --- a/examples/python/servers/bazaar/README.md +++ b/examples/python/servers/bazaar/README.md @@ -67,7 +67,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 4. Run the server: diff --git a/examples/python/servers/builder-code/README.md b/examples/python/servers/builder-code/README.md index dc118bdf00..15501283e6 100644 --- a/examples/python/servers/builder-code/README.md +++ b/examples/python/servers/builder-code/README.md @@ -33,7 +33,7 @@ routes = { 1. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 2. Copy `.env-local` to `.env` and fill required environment variables: @@ -62,7 +62,7 @@ You can test the server using the example client: cd ../../clients/builder-code cp .env-local .env # Fill in EVM_PRIVATE_KEY and CLIENT_BUILDER_CODE -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` diff --git a/examples/python/servers/custom/README.md b/examples/python/servers/custom/README.md index 50f46e6c20..68b0e588cf 100644 --- a/examples/python/servers/custom/README.md +++ b/examples/python/servers/custom/README.md @@ -50,7 +50,7 @@ and fill required environment variables: 2. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 3. Run the server: diff --git a/examples/python/servers/fastapi/README.md b/examples/python/servers/fastapi/README.md index 2b70e2e229..430ec380de 100644 --- a/examples/python/servers/fastapi/README.md +++ b/examples/python/servers/fastapi/README.md @@ -57,7 +57,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 4. Run the server: diff --git a/examples/python/servers/flask/README.md b/examples/python/servers/flask/README.md index 76a9d4e50f..0444a82c50 100644 --- a/examples/python/servers/flask/README.md +++ b/examples/python/servers/flask/README.md @@ -59,7 +59,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 4. Run the server: diff --git a/examples/python/servers/mcp/README.md b/examples/python/servers/mcp/README.md index 1b5892d1e7..93f1031166 100644 --- a/examples/python/servers/mcp/README.md +++ b/examples/python/servers/mcp/README.md @@ -26,7 +26,7 @@ cp .env-local .env 3. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` ## Running diff --git a/examples/python/servers/payment-identifier/README.md b/examples/python/servers/payment-identifier/README.md index d736454b5c..bef710d8db 100644 --- a/examples/python/servers/payment-identifier/README.md +++ b/examples/python/servers/payment-identifier/README.md @@ -49,7 +49,7 @@ server.on_after_settle(after_settle) 1. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 2. Copy `.env-local` to `.env` and add your EVM address: diff --git a/examples/python/servers/sign-in-with-x/README.md b/examples/python/servers/sign-in-with-x/README.md index 12f89a8d19..189cf4e662 100644 --- a/examples/python/servers/sign-in-with-x/README.md +++ b/examples/python/servers/sign-in-with-x/README.md @@ -66,7 +66,7 @@ At least one of `EVM_ADDRESS` or `SVM_ADDRESS` is required. 2. Install dependencies: ```bash -uv sync +uv sync --reinstall-package x402 ``` 3. Run the server: @@ -84,7 +84,7 @@ Start the SIWX client to test: ```bash cd ../../clients/sign-in-with-x # Ensure .env is setup with EVM_PRIVATE_KEY or SVM_PRIVATE_KEY -uv sync +uv sync --reinstall-package x402 uv run python main.py ``` diff --git a/examples/python/servers/upfront/.env-local b/examples/python/servers/upfront/.env-local new file mode 100644 index 0000000000..aeea0693ab --- /dev/null +++ b/examples/python/servers/upfront/.env-local @@ -0,0 +1,3 @@ +EVM_ADDRESS= +SVM_ADDRESS= +FACILITATOR_URL=https://x402.org/facilitator diff --git a/examples/python/servers/upfront/README.md b/examples/python/servers/upfront/README.md new file mode 100644 index 0000000000..4d065248af --- /dev/null +++ b/examples/python/servers/upfront/README.md @@ -0,0 +1,91 @@ +# x402 Upfront Example Server (Python) + +FastAPI server demonstrating the `upfront` payment flow on the `exact` scheme: the facilitator settles on-chain **before** the route handler runs. + +Use this when the resource needs on-chain finality before execution — for example, long-running handlers on Solana, where the default `authorization` flow requires the handler to finish before the signed transaction's blockhash expires (~60–90 seconds). + +```python +routes = { + "GET /weather": RouteConfig( + accepts=PaymentOption( + scheme="exact", + price="$0.001", + network="eip155:84532", + pay_to=evm_address, + extra={"paymentFlow": "upfront"}, + ), + description="Weather data", + mime_type="application/json", + ), +} +``` + +## Trust model + +Under `upfront`, the payer commits funds before your handler runs. If the handler fails after settlement, the client has already paid; the middleware still echoes the settlement receipt in `PAYMENT-RESPONSE`. + +## Prerequisites + +- Python 3.10+ +- uv (install via [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/)) +- Valid EVM and SVM addresses for receiving payments +- URL of a facilitator supporting the desired payment network, see [facilitator list](https://www.x402.org/ecosystem?category=facilitators) + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `FACILITATOR_URL` — facilitator endpoint URL +- `EVM_ADDRESS` — Ethereum address to receive payments +- `SVM_ADDRESS` — Solana address to receive payments + +2. Install dependencies: + +```bash +uv sync --reinstall-package x402 +``` + +3. Run the server: + +```bash +uv run python main.py +``` + +On a paid request you should see settlement logged before the handler: + +``` +[upfront] settled (before-handler) tx=0x... +[upfront] handler running (settlement already completed) +``` + +## Testing the Server + +You can test the server using one of the example clients: + +### Using the httpx Client + +```bash +cd ../../clients/httpx +# Ensure .env is setup +uv run python main.py +``` + +### Using the requests Client + +```bash +cd ../../clients/requests +# Ensure .env is setup +uv run python main.py +``` + +The 402 response includes `extra.paymentFlow: "upfront"`. + +## Example Endpoint + +The server includes a single example endpoint at `/weather` that requires a payment of 0.001 USDC on Base Sepolia or Solana Devnet to access. Settlement happens before the handler returns the weather report. diff --git a/examples/python/servers/upfront/main.py b/examples/python/servers/upfront/main.py new file mode 100644 index 0000000000..da7bb858f9 --- /dev/null +++ b/examples/python/servers/upfront/main.py @@ -0,0 +1,96 @@ +"""FastAPI server demonstrating the upfront payment flow on the exact scheme. + +Settlement happens before the route handler runs — useful when the resource +needs on-chain finality before execution (for example, long-running handlers +on Solana where a signed transaction's blockhash expires before the handler +finishes under the default authorization flow). +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from fastapi import FastAPI +from pydantic import BaseModel + +from x402.http import FacilitatorConfig, HTTPFacilitatorClient, PaymentOption +from x402.http.middleware.fastapi import PaymentMiddlewareASGI +from x402.http.types import RouteConfig +from x402.mechanisms.evm.exact import ExactEvmServerScheme +from x402.mechanisms.svm.exact import ExactSvmServerScheme +from x402.schemas import Network +from x402.server import x402ResourceServer + +load_dotenv() + +EVM_ADDRESS = os.getenv("EVM_ADDRESS") +SVM_ADDRESS = os.getenv("SVM_ADDRESS") +EVM_NETWORK: Network = "eip155:84532" # Base Sepolia +SVM_NETWORK: Network = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" # Solana Devnet +FACILITATOR_URL = os.getenv("FACILITATOR_URL", "https://x402.org/facilitator") + +if not EVM_ADDRESS or not SVM_ADDRESS: + raise ValueError("Missing required environment variables EVM_ADDRESS and SVM_ADDRESS") +if not FACILITATOR_URL: + raise ValueError("Missing required environment variable FACILITATOR_URL") + + +class WeatherReport(BaseModel): + weather: str + temperature: int + + +class WeatherResponse(BaseModel): + report: WeatherReport + + +facilitator = HTTPFacilitatorClient(FacilitatorConfig(url=FACILITATOR_URL)) +server = x402ResourceServer(facilitator) +server.register(EVM_NETWORK, ExactEvmServerScheme()) +server.register(SVM_NETWORK, ExactSvmServerScheme()) + + +def _log_after_settle(ctx) -> None: + print(f"[upfront] settled ({ctx.phase}) tx={ctx.result.transaction}") + + +server.on_after_settle(_log_after_settle) + +routes = { + "GET /weather": RouteConfig( + accepts=[ + PaymentOption( + scheme="exact", + pay_to=EVM_ADDRESS, + price="$0.001", + network=EVM_NETWORK, + extra={"paymentFlow": "upfront"}, + ), + PaymentOption( + scheme="exact", + pay_to=SVM_ADDRESS, + price="$0.001", + network=SVM_NETWORK, + extra={"paymentFlow": "upfront"}, + ), + ], + mime_type="application/json", + description="Weather data", + ), +} + +app = FastAPI() +app.add_middleware(PaymentMiddlewareASGI, routes=routes, server=server) + + +@app.get("/weather") +async def get_weather() -> WeatherResponse: + print("[upfront] handler running (settlement already completed)") + return WeatherResponse(report=WeatherReport(weather="sunny", temperature=70)) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=4021) diff --git a/examples/python/servers/upfront/pyproject.toml b/examples/python/servers/upfront/pyproject.toml new file mode 100644 index 0000000000..38d26c083d --- /dev/null +++ b/examples/python/servers/upfront/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "x402-upfront-fastapi-example" +version = "0.1.0" +description = "FastAPI server demonstrating the upfront payment flow on the exact scheme" +readme = "README.md" +requires-python = ">=3.10" +keywords = ["x402", "payment", "protocol", "http", "402", "upfront"] +dependencies = [ + "x402[fastapi,evm,svm]", + "python-dotenv>=1.2.1", + "uvicorn[standard]>=0.40.0", +] + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "mypy>=1.0.0", + "ruff>=0.1.0", + "black>=23.0.0", +] + +[tool.uv] +required-version = ">=0.11.0" +exclude-newer = "3 days" +package = false + +[tool.uv.sources] +x402 = { path = "../../../../python/x402", editable = true } + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "B", + "C4", + "UP", +] +ignore = [ + "E501", +] + +[tool.ruff.lint.isort] +known-first-party = ["x402"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true diff --git a/examples/python/servers/upfront/uv.lock b/examples/python/servers/upfront/uv.lock new file mode 100644 index 0000000000..8e8079d962 --- /dev/null +++ b/examples/python/servers/upfront/uv.lock @@ -0,0 +1,3703 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version < '3.11'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "bitarray" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/c2/ac331091a307bf9f56b7a0f9a8fb4916158bf8dae3a97edebd91f43c985c/bitarray-3.10.1.tar.gz", hash = "sha256:c33e48906407ab3d0edb96cc5ab2a599bda5dd04704ebcd9b3e0eedce7310e0a", size = 168648, upload-time = "2026-08-07T06:21:16.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a5/185c9928c1c52ed3817f8efa272df791939921effba7ec46abc2c7041e63/bitarray-3.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fdcdb64d155ebddc2adca2d9eabf3ae93f3e2a1b244bee5a7cb36b75f16259cd", size = 163829, upload-time = "2026-08-07T06:18:50.472Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/2155f96432df0087357c9f24fa90f58b85980c30e138645fcd0325d58269/bitarray-3.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b42adba4705a4e461ff3c76b89b1622a15c26759101226a66419adab60632035", size = 160257, upload-time = "2026-08-07T06:18:52.254Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/1c0c75669cfc84b60a760baf8e140b0f12a44b4bcf91f048e0166dfe1224/bitarray-3.10.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c3adbe1ae3dc8e58a5a2ea582427deb49221a3a965656620a4a0cec71b8a09e", size = 357751, upload-time = "2026-08-07T06:18:53.493Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/0903cd2eb5db8b9c47ed545c35a2080bcdf9dc17e327ee8399718ef789f3/bitarray-3.10.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:767f804f79ab12ff7084cc4a35d69f11190940215fe42f95e5997d1ca7775a81", size = 379229, upload-time = "2026-08-07T06:18:54.633Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/625d971054404d40cf98e84ace13d920cfeaac27f93abfc130be2d996f01/bitarray-3.10.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c811f978e9aff15c1ecb7ad7b44432fa8e83e8bfca89e6fd2e13a74e7d55ad6d", size = 392064, upload-time = "2026-08-07T06:18:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6b/31dde6be834b152992c0a8ef1a402c6446d836f978d32c9bce2552de5977/bitarray-3.10.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12559dc14eee8f43dc80c7b86f2850319e3e05ac63bdaf52c0b7eae09279420d", size = 360556, upload-time = "2026-08-07T06:18:57.709Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/0e4af096fc426b9d86825c09ccf4b2dbd37f56645ece3017ced5093238fa/bitarray-3.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e364823a83ebe61942bcb0f959362b06f4902e4bef6273736ce8cb0b2350fdab", size = 355574, upload-time = "2026-08-07T06:18:59.107Z" }, + { url = "https://files.pythonhosted.org/packages/79/d1/de16bd15623593c1008f5c5e11396a5f05f07be4e726fa4af2ff17c0197c/bitarray-3.10.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3cc58d6ce4bb9e10cd96a78721ac2d9a1eed10962b49969fa3e2623cc66376", size = 377093, upload-time = "2026-08-07T06:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6b/460b24a56d063976aeda3cf42f848823c02235d64eb5e7e9d371a7a84fa4/bitarray-3.10.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8882b2bb6b30dbe09ed20cc0c7c934ff8e50f6aefb92d6de233b877e2b008535", size = 373170, upload-time = "2026-08-07T06:19:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/4214bdbd0f54bf6677062c0a91bf189c8be12b07d6dc77de57f4f464394e/bitarray-3.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff196f126899a20113914343e35b629700abb07ae7abb117fb91d2a081f2e93e", size = 357621, upload-time = "2026-08-07T06:19:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/05062d9eda3e89ec3a6da40e134832aab1966ea9a80d44685e6ce09c3e0a/bitarray-3.10.1-cp310-cp310-win32.whl", hash = "sha256:a5234a4eac63f3c1d73783e51d7a133e19a1338f32ee57f646424555c59a42a3", size = 155500, upload-time = "2026-08-07T06:19:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/83/a6/7ae385aa9039f10ad8f6db14fc3ccf04f87bc3f3cfb3c3444fddff30feaf/bitarray-3.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:4be66735a49abd28abc366e4ea624b6088cf4b670706a5c7a0852625b4b55942", size = 164731, upload-time = "2026-08-07T06:19:05.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/9d8f3ea07bab7eb1beb110eb9e15623665b60e5fa219dadd9bd1152aca56/bitarray-3.10.1-cp310-cp310-win_arm64.whl", hash = "sha256:199826c1be10962b8b0f7919b6be735bff29213ab0b501c3c16d318e4b019221", size = 160674, upload-time = "2026-08-07T06:19:06.364Z" }, + { url = "https://files.pythonhosted.org/packages/75/47/8c630d011b623e070fc3907cbedd2014226613c06f9c0ed15901c88df3ac/bitarray-3.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:840b1becf4eaecc2252055484126be0032ff578ac18553a2d9a68d54dec498dc", size = 163825, upload-time = "2026-08-07T06:19:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/15e36df4bd2ff40de433d7c3a53c7cf1b37ba513a44aa2b572e0f137614a/bitarray-3.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bbcfbd52aedbe20224226bfef8b1087eba43822920f53c68d4a2537358f3cd96", size = 160256, upload-time = "2026-08-07T06:19:08.714Z" }, + { url = "https://files.pythonhosted.org/packages/eb/82/01fcd910b6204913f20aacc88c111662bf0b6f9b696964c3c623db5dd699/bitarray-3.10.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:948fe93ee373f33655e984b5c77324a97fd11555f16e4fd60d4e8d9a79dabc49", size = 367018, upload-time = "2026-08-07T06:19:09.884Z" }, + { url = "https://files.pythonhosted.org/packages/33/66/79745d05936d197d46d6d2eda06174e748f5795775d5e28568d1be514281/bitarray-3.10.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19a95f24eae45c0c7d0786b57427a4c0a0d9470ff088550ba035d94d7d6f6789", size = 388227, upload-time = "2026-08-07T06:19:11.338Z" }, + { url = "https://files.pythonhosted.org/packages/83/58/ac31955e03004ae4f64e0d55ca5b9b4e5d89543a4dd0e268004be09b76b9/bitarray-3.10.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b2c5159305f1f2478354f3f35f69a599d36203ff14604d98e4d6bb2c2e21e34", size = 401511, upload-time = "2026-08-07T06:19:12.55Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a5/28c6fed96e38ad8e4a3855105bef34e5f998a315633adc6d93e63aba727d/bitarray-3.10.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fee04f0594df712c2721e8178b11a36ce3914587672601a946b7a2dd6ba0223", size = 369464, upload-time = "2026-08-07T06:19:14.111Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/ac006f57907514f075c4b32a8120f363f883b2b8c1b0ad191e3ccb73f30a/bitarray-3.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4ef82b0f1b53831496b59d34d979cea5116a5d0ec55c726f7b063773f44a698", size = 364580, upload-time = "2026-08-07T06:19:15.349Z" }, + { url = "https://files.pythonhosted.org/packages/09/a2/13c17f6ea6b3b1fe780f746ab6449e34be5d7b54b65d290cbbeda3f1f00b/bitarray-3.10.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a201bb9f53c0550988b0743de32d27c4d7e00d35b25604aa6168649c1f20f5b2", size = 385121, upload-time = "2026-08-07T06:19:16.671Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/a4673b78328fa3d37407b3644075ffbed0d1c40f6fbcac76ea35a151c546/bitarray-3.10.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67601981cf27e0379cf6a8824a40ecc88d1ddc138a5df2a1cbf8973d37bac087", size = 382175, upload-time = "2026-08-07T06:19:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/8aa203c634a27caebd8efb2996b0c06bc133db1384a35cf1c5d4798fce19/bitarray-3.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9539d1d3b2b07349510cb1ead1f25d671c80ed8c07d6a709ab2334f4eff03605", size = 366507, upload-time = "2026-08-07T06:19:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/d27fef681afc73400447db6e80051709377124350b7526870d9c1987021f/bitarray-3.10.1-cp311-cp311-win32.whl", hash = "sha256:4545c9a3938d9e335412cfea384618307638cc4b8a3f7dd21060f19d6287e48a", size = 155660, upload-time = "2026-08-07T06:19:20.815Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/4f4334e0d9abb4cfdd954692660602d90ffbb6e4c9233ea4d5e631660818/bitarray-3.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:be2526caa2b76e902edc1310a4d69940954c24f8566bec092372aa9743b668ed", size = 165162, upload-time = "2026-08-07T06:19:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/18a3eda90698a5a110d85d8bad51d6ac862020bec89d1541a95277181bf5/bitarray-3.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:148e8d1d1805bab05a68c322a0a5e817e728a4c09b5ebd484f39463abbcdd561", size = 161046, upload-time = "2026-08-07T06:19:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/38/ce/c2630dfef2d10460e67083c1bf8e3422a8f7d66fc75b02a3d7026e372e6b/bitarray-3.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d050414e41e67dc2f41a73a4755f322d043e0c1100d53bc2f9d7d3ead742a86b", size = 164121, upload-time = "2026-08-07T06:19:24.29Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e6/b0861dc786fa06a5d942c424410d41ec245093f2818b8c69d499c999af24/bitarray-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b8631479fea515ea22c3992a4d6ded44e0475237b652aaec9c9f1c70a72179", size = 160224, upload-time = "2026-08-07T06:19:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/23d33d381840ade968b92b683a5f3c9e882a58b731705c0b8b666ce214f8/bitarray-3.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54db9a52b2f6dfe0e4549ac1842c9551f030bfb929474d18e7b6cd83e05cfd55", size = 369452, upload-time = "2026-08-07T06:19:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/9e/02/b4bfdbd22dc0ac9bd853dd43681d8c1aa93c8ee93021eb2dc8f99edf6204/bitarray-3.10.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:072093df2829ae426fd73c49761dee9e72d68d42a08102d61d04443413da96c1", size = 389854, upload-time = "2026-08-07T06:19:27.728Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/77c6f9d8b7f2eadabcb82871812eca7417b55675f1dab5d8f3cc1163ce3e/bitarray-3.10.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dae569d644af8e28576e2f750985aa480fc51a810ba0e3e2cb1bee72619a6ed", size = 404371, upload-time = "2026-08-07T06:19:28.936Z" }, + { url = "https://files.pythonhosted.org/packages/9e/96/cb01be7ef83c45c2578c58735898375e5d43b32cec6b5e6e6c4081a6424a/bitarray-3.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfaadcc4d72cb2855116ac7d164d62d87f668a43bc4f9034a21676688325ac80", size = 373703, upload-time = "2026-08-07T06:19:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/22/18/d9816a6fdac717869896c63bb9e5b60be2470188f0288ee60b3cdf8dd076/bitarray-3.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7d6d66564add2d5f9405e754058a0acbbd4475d3de5b607c2f8fc32e82d6b000", size = 367129, upload-time = "2026-08-07T06:19:31.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/58/b26cad93fff9fd761a82f22c33ea91a55f9849210b358730d34e3a0becf1/bitarray-3.10.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1ed01b4b692abbfde7461314f473877604846195a06a2e155c5ec1bb7f1adc9c", size = 387259, upload-time = "2026-08-07T06:19:32.952Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/9f09360d18197b1d1c58d31e12a305ddf019d31a1b8c3c3a38451ac1c7a3/bitarray-3.10.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6e043d9d35d78891aa6e096f59c850c549d7da03c2a706b79834073806d170d7", size = 385708, upload-time = "2026-08-07T06:19:34.221Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2e/4f158817042ff2a5ae7970bdc05ee8b1b09d695c10dba457b135618ba29c/bitarray-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8474e2ba90091424f1e3f05740939d2bdcce1319ad63ec4aba832910dffb4257", size = 370255, upload-time = "2026-08-07T06:19:35.477Z" }, + { url = "https://files.pythonhosted.org/packages/65/c2/5195493edc0a741b42dbcf1e95b7c15ece4c9a36179a67d2d7ebb461efe1/bitarray-3.10.1-cp312-cp312-win32.whl", hash = "sha256:8d3dcc6e5c24ba7b6f581446b153fd8575b373149bba6fbcbdc2f109d6a30417", size = 155969, upload-time = "2026-08-07T06:19:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/9f42846bf195b4fefebf95b26ebdb6f81e18871058caf3e470b0a973cd94/bitarray-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3138fc1695f4fc540ce8409e5478495d3ec1796ffcd2ec8254443cd2f6a779be", size = 165510, upload-time = "2026-08-07T06:19:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/94/79/8da80c98f44b629721cd2fdf998f48112539f73f58f1c23bdeefbced03c6/bitarray-3.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:019d072e59b8f8cc8524794cccef857a31138e065c2cdbb902dde21f3e7e9744", size = 161195, upload-time = "2026-08-07T06:19:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/f8/47/fa4457d3ba8af856c6d6ab409db28301d986726c92c29faed316bb973aff/bitarray-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1edaf829d5fbf0640c211ce83171ade249fc4ea22ac5b87143697e443495c18", size = 163835, upload-time = "2026-08-07T06:19:40.298Z" }, + { url = "https://files.pythonhosted.org/packages/b9/dd/e909115ca81594fbd291c0240fb8727261356bfb5ff8edbf281d6fb7bed3/bitarray-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1174731b7c182cdc2a70ed860b98a912532fa9cd750459916beddbaa98a18c4", size = 159955, upload-time = "2026-08-07T06:19:41.501Z" }, + { url = "https://files.pythonhosted.org/packages/6d/92/bbe15d9da706dbcce9e11569d244930baafa689740aa9746355c5e4523f6/bitarray-3.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b064620234f38ded80c455f2c77d62af932fe5fc14e0549ce5c41613d5509f1", size = 367725, upload-time = "2026-08-07T06:19:42.664Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/caf98c29374aaff77d2ef74d95cc5531cfa90dd102bd317c64b08629b0bb/bitarray-3.10.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:41d6b9670792383e6c604cf96dae9de44a79cbb009068f44bd1350edf4db9283", size = 388403, upload-time = "2026-08-07T06:19:44.035Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/6cd2f2fc8a4e94c29f2239c5f9a475560dd7feaf548b78306c8b1c9d345d/bitarray-3.10.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9601cac1be19a87056c3ebb53a6fd0e695b4c2f2482c95fbc21f45ac737b384f", size = 402344, upload-time = "2026-08-07T06:19:45.354Z" }, + { url = "https://files.pythonhosted.org/packages/e6/62/0049b42dbfa5cb8d584a930a611b11073ad74a34b12b0e1dd8542b56b05c/bitarray-3.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4145177fe23045e7f416eb7a2b12f34678b5e67e7efed619f79b384b85dd2d4a", size = 371608, upload-time = "2026-08-07T06:19:46.901Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d2/372e8eb8ee0f02215d194136607b5923b44b5983aebada73e82433704aa7/bitarray-3.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:47ad63d66add4a1d39c75377b407c22027e72100a60f06c5bf854c6064c74875", size = 365103, upload-time = "2026-08-07T06:19:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/02/f6/97a561b8798b8f41f4b18b562474d8887df96d24b2bf5d0f978346bac6e8/bitarray-3.10.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0de09c7c2e366371546de3d88b8d4d5f7d2be38eebafd775981c46919171a0b4", size = 385059, upload-time = "2026-08-07T06:19:49.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/63/b9a3885b9c9a9f1816fcea765f33148c2e9c411c40faa3c00f2bd9dcf035/bitarray-3.10.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:66ac3632d2b79e57f0b762be0f331d871ff965e119a54e6e378025614736beb3", size = 383637, upload-time = "2026-08-07T06:19:51.763Z" }, + { url = "https://files.pythonhosted.org/packages/70/65/6c248b95306519a1eb56b03ce611dce96c999c70a2620d6f40dc21c2ba6d/bitarray-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5bbb74b86f4b7b017dd29161b6f05f32f7b49f3076b274de3cafb3180e11c990", size = 368124, upload-time = "2026-08-07T06:19:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/5ed21f63cb07aaf688b1609a84ffe95508f5d07ca89bf7270c0a09914aec/bitarray-3.10.1-cp313-cp313-win32.whl", hash = "sha256:e7bc260f090a57e5963427e29c47b32a5acaf89500e40e68809d981348e0c893", size = 155825, upload-time = "2026-08-07T06:19:54.68Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0e/f14360ee3929faeb5bd7a86b3b97e92558c48864ff210ca13704a27f5ef5/bitarray-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:a9a47f8fe495395176f694c2b3454848ff750783c599b7ad98c689f5c2065892", size = 165360, upload-time = "2026-08-07T06:19:55.92Z" }, + { url = "https://files.pythonhosted.org/packages/ba/40/7211826b639938c85fe189d984263959713a8955c284d6247a2295915a46/bitarray-3.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:13a58b399414422e9c0462f069511992044ae39ae4b33b176726535a0ce3fb06", size = 160977, upload-time = "2026-08-07T06:19:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/bf/19/8309c1d9817311cdc90e6d79cc2df899114c11be1bc92a79b66ebea22596/bitarray-3.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:745b54b772c5e399f37fb22cf09d73f0ff1bc7dc2e25308628c9d3bb39a99e04", size = 163832, upload-time = "2026-08-07T06:19:58.546Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1e/1b19f398a6ab37fccaabb8af725e8bf9740c9bf6c25cde624e6731e4ec32/bitarray-3.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0c03d3d90ec3abe36be8ef6c01a0dd7c47bede4a36a16d0c0bda9d152725219", size = 160209, upload-time = "2026-08-07T06:19:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/3357c5e4098b98c84003b3112ef488c23b8c8c7b606353c1f49da30e9eec/bitarray-3.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6020bfca6e85b3ba551cc056ddb5cb76ae050bcad840fc541520cc3847d89a68", size = 367651, upload-time = "2026-08-07T06:20:01.456Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/04ddd055a29052a0780421922ab47f99488308f4bfde13153a5f3597cf21/bitarray-3.10.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:067122845b0d8e9d820a390c3516160a699dd6c31c2c71028cc787fcf0f1b632", size = 388405, upload-time = "2026-08-07T06:20:02.988Z" }, + { url = "https://files.pythonhosted.org/packages/b5/93/4e121148ad8d0155929764810ba6ac9eb689c75f192fa8997082c9425b17/bitarray-3.10.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0531dce521e00d0d9ee36984f8b198bcd3455f1b8b3cc635efe21114b88e74b6", size = 401716, upload-time = "2026-08-07T06:20:04.444Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f3/5d43ffc866b4398080452bc301ad39bacc87b608f9fa83ec55bce94b8b6d/bitarray-3.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6de68b321bcbd8f2cde7c84b0afca22855e3671ba750fc4610ae4f5cb78b57f", size = 371169, upload-time = "2026-08-07T06:20:05.798Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/f236234097a31369d22dd65b2f0d967d6fe63c20e9eb8a59e96e20768795/bitarray-3.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:11be8151782d755c60d65d53f81bc33f7c64dca0a5688657a8978afc667aad00", size = 365065, upload-time = "2026-08-07T06:20:07.239Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/75770317650289958b3cf9a6b98c747c3c1d24dc2b6c3c60078dbc5affb7/bitarray-3.10.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:e5dc3377a2ae3405961218dca5402ca4b7acf96d1873ffb312a4a21a7c3788b7", size = 385277, upload-time = "2026-08-07T06:20:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/85/12255d8187f59776a24ce6247624ba1196c330a44396ab8cd6a87d511b0d/bitarray-3.10.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2454206e6066dabace55bca254889d26f6d77fd91693c35efe503617c844e62", size = 383409, upload-time = "2026-08-07T06:20:10.147Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/fb2a1541248ec45683e1c48e320e49a61ef43ce23d9b59c2dabbb0000fb4/bitarray-3.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2be5b44ce2e2b92d5d1a0eab0f3b2ddb9caa2ed4efdee4059a6a19c8b2da7b5", size = 367818, upload-time = "2026-08-07T06:20:11.466Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0d/6af949fea9d726e6814898782efc55da0dd7c7eb5d305bd96aa5843f3d64/bitarray-3.10.1-cp314-cp314-win32.whl", hash = "sha256:e37c125a287de5e31d973fa77ed044f2438e413d98c52d8f15533beaad410fef", size = 154619, upload-time = "2026-08-07T06:20:12.852Z" }, + { url = "https://files.pythonhosted.org/packages/ad/46/ee499430e89062eaa79c1afc16ac1e9bf93a53a9beddcd9195b4abf43215/bitarray-3.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:d85840fd0999ff22a26b8bd0a6e4695d85ea8515af9e2be9f37afffd4ce7d12d", size = 164313, upload-time = "2026-08-07T06:20:14.213Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f5/dcd182ea4cfd11965917700e1944ce9ece1c255b90b03ba9e1e61bdc04ca/bitarray-3.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:c9cd67a2a85f4c897b73714d4f856838c587d653e2cd67dba5a555f16390ef20", size = 160470, upload-time = "2026-08-07T06:20:15.491Z" }, + { url = "https://files.pythonhosted.org/packages/1c/89/1269cf6de1ceee6de98f22dfa6e10885014bc7580f81b5c827fae8a398e4/bitarray-3.10.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c93b6e77c5f16d28ef8d25551b6e6eaf162de51e76ed182c2186de14d367487c", size = 167063, upload-time = "2026-08-07T06:20:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/05ee259804b83c6a967beb1fe39e48ff3f6afa2abebe4faf2a8a4d6c7e9a/bitarray-3.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:333f57b1095d89d5d71dd862954457f288462fe7c69bccfe416e56812fd8f037", size = 163574, upload-time = "2026-08-07T06:20:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ea/dd37423c9e4b44c7234010c7fbd45e196e39381905314d3662790011c99a/bitarray-3.10.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a989ad017ac6aa2512e17f478d953de2a457bdfc25f5dc974d1d0caedce42921", size = 386189, upload-time = "2026-08-07T06:20:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/caf35ae9d79b87be8b7b2be36015ab65ace9a4ad770a1862932636986c1a/bitarray-3.10.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2fe3018890da7c0c80a06259977ea7d112d3d06489bfad8209486d6aafafc8f", size = 405403, upload-time = "2026-08-07T06:20:21.612Z" }, + { url = "https://files.pythonhosted.org/packages/92/72/d7c217ac293353fc9a8b85e50c54c012eb4793c5e868cdcbd964a54ac0f5/bitarray-3.10.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b02744ea565e0878727ea5dcd9373397ade9fb8a9bffb81f0c0b79859e5b433", size = 418890, upload-time = "2026-08-07T06:20:23.143Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5f/3d9afb63f87e3ab366de7f6934c55d5784ac51b1e3cae30b013bebfd0d89/bitarray-3.10.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:587727a15fe5d9b7d4938b816028d39a51b4660bf564cafd625da3a3457fb75e", size = 387815, upload-time = "2026-08-07T06:20:24.593Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c1/e8472b8a119e30a3317476beb2c3175421b936315641dccfaf1c6551add4/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:11d9102d42b549a702852afb21df481eec7bf1cf870a04e74f9f26652ed0ad0e", size = 382009, upload-time = "2026-08-07T06:20:26.347Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/363bc1435f64c19c71904b969f51dfd8d0a2afa815c1d976a6c2140f148d/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c1e35dd7aa6e9a0c11bf7ab25ec26627db0111250632b2df859ce5fcd00b4178", size = 401433, upload-time = "2026-08-07T06:20:27.993Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/2cb33a28fe665a8e4541db7f28fb241b44e8a8f8159cb9baec81c85e2c64/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a11e3a8806f221598b05714181c79d4ffbeb984136231b4cdce2435a17772e67", size = 399566, upload-time = "2026-08-07T06:20:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/e65b0069c4cca0c964ca35b9b645e9f74afdba5941f14d2c266633d228eb/bitarray-3.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:95f8913893cf8c9a7c161b9bcd922e42e4901ea2a768b763e21fa8f9765917ee", size = 383813, upload-time = "2026-08-07T06:20:31.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/9e/3f86a88b7814e61a6a04e7c85f80dd8c603d7027cc8fd959ad6f611e6406/bitarray-3.10.1-cp314-cp314t-win32.whl", hash = "sha256:06a924dad0562df6bf8a22b22a863b3b9f9f73d118ca1bdbd69322e05bcae0df", size = 157972, upload-time = "2026-08-07T06:20:32.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dff4b97c2ae91062544e95d52f5c447649c01fc7b1ed56c2f7dc7b67e9f8/bitarray-3.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0cf84cfbefc6a8238062af0ff06688e5b63884ff5c2eaf4c9ca5f74423c4c3c", size = 167865, upload-time = "2026-08-07T06:20:34.088Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/e95e7891e628d90c69a4b9e11af7aacd4590b23a36965f4a21b3ab5917f8/bitarray-3.10.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d5b6d177473ad758637a9d03205f99e3b9a16fdec949b32a8814e8ecf6a3523d", size = 163676, upload-time = "2026-08-07T06:20:35.448Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "ckzg" +version = "2.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/88/552337d9fc69dc85fb6102c18b73a9f3f77efb39bb9a0c1a8c61bbdd7274/ckzg-2.1.8.tar.gz", hash = "sha256:d7bef6b425dca6995457fc59fc5b30211d9b28cbbeee0e7a7bef1372e13f29ca", size = 1128002, upload-time = "2026-07-09T23:02:13.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/2c/52d9b6d87842a4ae308e4e94ef9c6803293ae51a204ae640ad6c705a3c56/ckzg-2.1.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8dc23b189b5e061b5a2fcf609b6dc1f62098dd72cf9116e4721f995eb144b0ee", size = 96612, upload-time = "2026-07-09T23:01:04.146Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/f9b8181bf9e0d05737f76f127561a8186892454d2b3895560d186d0afa8a/ckzg-2.1.8-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:71e0e6d84a268c23c29d546a7ef4720a9d2aea597efe4def75205b6ef436900c", size = 179939, upload-time = "2026-07-09T23:01:05.46Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/45a3fa698526e5eae80884c4f83791b15483df162ce3f8a68227c5c46b50/ckzg-2.1.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ccabbd5aea9cf98ae5301ec8b16119720d93f8f155a567e041e2f16b1fafc87", size = 165889, upload-time = "2026-07-09T23:01:07.486Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fd/9bc06cd017527536ad4fa77877901db3cc0f7a954a63815e88e54610de62/ckzg-2.1.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1dee4ec1d8e6702e6008a4d489638027cd640b5d9e5adca48a8c16aa193c6c7", size = 175702, upload-time = "2026-07-09T23:01:08.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ed/a63f296cd3fcf01d53104e97704daed8aa3bb34bdb65ea19abb88f704012/ckzg-2.1.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:697f5c4016a9f091bdf7e646be0d71e625b5106a9f78c6ab53e93e5f425aa513", size = 173310, upload-time = "2026-07-09T23:01:09.999Z" }, + { url = "https://files.pythonhosted.org/packages/33/74/30744f0c124ca6e9487b7e0a7183e3f6a69357c165d1a10e1df41d981e97/ckzg-2.1.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:50f6281f5c55240f1be254041e0c06d6348c91e21fccd0ed108151f880b6d087", size = 188492, upload-time = "2026-07-09T23:01:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/6a021579ccf1ad36d7249813c3242240a969b1580909ecaccbdc2146a728/ckzg-2.1.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9371ac89c420beb6f6d236ce808c7990174dc21bde61032c39235982e4e65697", size = 183095, upload-time = "2026-07-09T23:01:12.496Z" }, + { url = "https://files.pythonhosted.org/packages/9b/cd/32635348bec82b98ac99b06b47874db8f1eac5837417c95178daf20fca3d/ckzg-2.1.8-cp310-cp310-win_amd64.whl", hash = "sha256:c1f0f8e7a14bb4348f3ecaee92d929df3428be1f706034c961e11239d398bcdb", size = 103239, upload-time = "2026-07-09T23:01:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/7a/bb/925bf38dfb4ab47857a6f5cfc934e9c0aa3a375f0441e4818240d012e9bc/ckzg-2.1.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:adb4868ec73547d040d8551538dc00ebc3822a758a7fb2b762735e097556c13b", size = 96610, upload-time = "2026-07-09T23:01:15.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/85/8b303afe35a05fff4d3bf98233a06a38b046c19a0014f6653b3721fab505/ckzg-2.1.8-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:5e42a07899e62cf8888bdefe1fa200e3248190953bacc1d872e13825b0b91a7f", size = 180660, upload-time = "2026-07-09T23:01:16.253Z" }, + { url = "https://files.pythonhosted.org/packages/85/1b/d1180e918cfaf088e9d89251e02c33422ff0672c3996101221bbb0657b4e/ckzg-2.1.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:369af9ec31248a942f2b249bee9f60dccfe6c8ac944095ad26c8083c9df04186", size = 166647, upload-time = "2026-07-09T23:01:17.445Z" }, + { url = "https://files.pythonhosted.org/packages/90/dd/81cd6cd14658349a73966f48a2a2236717d0f14eec98a3c8164a3c1a6434/ckzg-2.1.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac8202240347c4af5cee9a3ebc62560ae629c8bac2b1ee313fa57cf1c9e4f0a", size = 176442, upload-time = "2026-07-09T23:01:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/3d27012bf3d6f96393524e32856cd12112bdcab1537d7f442b46537c8b2b/ckzg-2.1.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eff52513b32bc83e3b978cae991289cc932d801b6c08aa002d29e44a4290b2f0", size = 173940, upload-time = "2026-07-09T23:01:19.94Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bf/ddc00e09c1e2e886b35e16f68ee055a401756cb0ef815fcd62984fd9ae9d/ckzg-2.1.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:23bef3d98c905152f4ff497a6243b6a14e916deef2d8a1d342175be90f6e6425", size = 189225, upload-time = "2026-07-09T23:01:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/eb77d1ca605824d0fe5e70dd8aa904f2d72871f947b2070571abad5562a4/ckzg-2.1.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e6e33672e474331519b7d07b8c8e0cbec3d817b6341e00fa728a94141fa8e8", size = 183865, upload-time = "2026-07-09T23:01:22.469Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f5f18bbb3316a80a694a0e82849d2d2487cca88442dded395447491c924b/ckzg-2.1.8-cp311-cp311-win_amd64.whl", hash = "sha256:b84d698c81569381a3dd18a9848f8fca03273ae62045494cee5669a885697d8d", size = 103241, upload-time = "2026-07-09T23:01:23.778Z" }, + { url = "https://files.pythonhosted.org/packages/5d/46/4d9a53c00c24eca9055f2adf64382217b49f1eeea5af7d91915a8e74d236/ckzg-2.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:98abe138d79886e3e1fbbaf05cdf0702a4351f242ad1a8b4802343c7ba149faa", size = 96626, upload-time = "2026-07-09T23:01:24.911Z" }, + { url = "https://files.pythonhosted.org/packages/b0/90/f8a9befa5416fa3cd89ee04d76f55ac1990862d19d87dab124c1583e147b/ckzg-2.1.8-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:2fe01dad7c968bcdf3c063c5192bc7d7d59f66358afb5c99554e5ce2435a95aa", size = 180892, upload-time = "2026-07-09T23:01:26.01Z" }, + { url = "https://files.pythonhosted.org/packages/71/42/79280e02d7a8f7b4f97d581b013024c8695ff6a54cd4851f033d8a4733b7/ckzg-2.1.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bdb7f51ee3cf8e45451bee8c6dce975fddadfe231174d8de9c27a3aa27741b8", size = 166921, upload-time = "2026-07-09T23:01:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/43/af/d0ed7c7b2babfa76e91190a90e8e1f93729d63370493c2c79acfe9002abe/ckzg-2.1.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10339d23e36b8a0a4e6fda7f6c72d6b2fd4e1506f7b64a661ba8c706ee33f335", size = 176775, upload-time = "2026-07-09T23:01:28.866Z" }, + { url = "https://files.pythonhosted.org/packages/90/7e/3600096f33afa628e905cbb240733e46561d05b4bb3148f05bde0afe2208/ckzg-2.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4456c9027f2edcb50a2279d6035ca971a511d8b0025e6659ff407b87ad841ba", size = 174177, upload-time = "2026-07-09T23:01:30.069Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/c3cdda51c637852cefd785fc98fb9654dd439ccef09e4a3dd05c9ee498b5/ckzg-2.1.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88cbec7e2010f64988c249ebb5679b73ccae1c536f4c130d4708bf7b06a8cd69", size = 189432, upload-time = "2026-07-09T23:01:31.39Z" }, + { url = "https://files.pythonhosted.org/packages/3b/10/a04ac22d843dc5cbe97a6ea4be36db2bb9a001a93e3b3b18ac45693565e6/ckzg-2.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4d2581df10bdfaec00fec6daefdeaa438e66582364e1d1b705710e9d749fc47", size = 184231, upload-time = "2026-07-09T23:01:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/91/94/381f0c9ce5d6514b0141fd55117980c82aad1e8c93c91e5c3d30a5752e52/ckzg-2.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:a30f2b980929e898f0b28aa6bf9ae35e7afd5884e354376ad3744669b7cacf3e", size = 103241, upload-time = "2026-07-09T23:01:34.112Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/d41f083404fedc23721349b6497f8742be2d9b3d1273f23389683e4c65c2/ckzg-2.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26ed4c4d3acbfdb4bfdf1ab1029219657d4565e1c63d36f2695cdfdb5ec0b569", size = 96632, upload-time = "2026-07-09T23:01:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9a/7dc7e3673f77a6a7fcb8eb6593a1ad6817c0135f5207b350444a6bc468f0/ckzg-2.1.8-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:a6899908ca3a41e6d2aa19973b398de101a6d64b5189894077ea09a3f508d3fd", size = 180914, upload-time = "2026-07-09T23:01:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/5bbd60263520fec0e5cbcaf25a5ecab3621f9ce980d67d58cadb53f08a46/ckzg-2.1.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3db1ca21685d567eea668925c9f85ebd723db41d24bbecaa2f78d8256e1ba9c6", size = 166976, upload-time = "2026-07-09T23:01:37.665Z" }, + { url = "https://files.pythonhosted.org/packages/b2/17/dc58a11e582de7906305690801b62faf1b18667521ce6439831472c7bdf8/ckzg-2.1.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f27d7d16be9debf173369248ff06e97fc45826bfb0a743519b49b38539ab6c7", size = 176811, upload-time = "2026-07-09T23:01:38.881Z" }, + { url = "https://files.pythonhosted.org/packages/2d/50/1be2a98a1b37d0f98c77fddd3528b1f4c8dd4a9d0a07ec519ccd787f5c69/ckzg-2.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dadf0cd0c3c19611e8a1188a2a10316b88fbae56d806f75a67cbe846b8b7ec86", size = 174190, upload-time = "2026-07-09T23:01:39.94Z" }, + { url = "https://files.pythonhosted.org/packages/05/5d/ea050c82c3a86f712eae5ceb50ca3d4b5fa92442707f2023c15a1415e882/ckzg-2.1.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cde13789188b7bac6d5bee308532a7bd60ab5c2feeccdef2f1da41be761c7e04", size = 189470, upload-time = "2026-07-09T23:01:41.206Z" }, + { url = "https://files.pythonhosted.org/packages/d4/17/98db8284b30f75bccb4f860ee35dfd61d0644d7b0880b95b045bed11b373/ckzg-2.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f2737534458547ba8ce89663f833041925b104f7906f17b2338d1b36e6c7c4d", size = 184248, upload-time = "2026-07-09T23:01:42.374Z" }, + { url = "https://files.pythonhosted.org/packages/66/9a/b979219005a38fa172aaf811516913cda1386fb9c08017f9883ab710f009/ckzg-2.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:10b483ad6937878f03d556d120a43d323dcb3891eb83313aa71087b54559594b", size = 103243, upload-time = "2026-07-09T23:01:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/cb5aa31fa8ee8522f28fabfc8abbbb0deb36ae8cb28255060378c6efea04/ckzg-2.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f3e4a3ae1ff3ec811b6d2aac7a246524f72a750af95fe7a01550cdd68677d6f", size = 96628, upload-time = "2026-07-09T23:01:44.724Z" }, + { url = "https://files.pythonhosted.org/packages/a4/42/554f1fadafa3f1100049701c5c7dca9e317f8388607a5ba46f780248218e/ckzg-2.1.8-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9afe50a28d8d6f130797f0861d4753f76294d983f1e6ead9c17dfa14f8118ad4", size = 180921, upload-time = "2026-07-09T23:01:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/054735d639798c81aefc219ab6adf543cbc33192f127f4c396f16fdeea4c/ckzg-2.1.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da197eef7014997b976ae7bc6e0ad42c02a9faa3a4221d6699e8b777761422f1", size = 167037, upload-time = "2026-07-09T23:01:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/a535ec2a40bcbd746c95e762cf788b0b07559958ec58a2f2c55c037003b8/ckzg-2.1.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fef164b9a0c7ed57935bf78bd23e701ba82efcca180e9b29814a94928a2d5880", size = 176800, upload-time = "2026-07-09T23:01:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/71/f5/be114e08e6d9457c840ff286c58b3478ba445f4d763e36a10c1257074cbc/ckzg-2.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c56f588a57d419ac314880931122796e4331be395368c8887ca3ade5c27f539c", size = 174296, upload-time = "2026-07-09T23:01:49.464Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/f6b566f79d7910aeb56f1894e40f8c45b01cd34936ab0a05ac335b5d3de4/ckzg-2.1.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:37eecbce59271040dfab736db490a28c0e59f189b404e5820e65531dadf84ddb", size = 189475, upload-time = "2026-07-09T23:01:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a9/2f9428c2a662e79e0a3f1006b988b3dd4cd319172847f45220c3b8ab763a/ckzg-2.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acc5d33e42ac852fec08ad1991020a958e2534ac6af346a83f579b553bd0bdfd", size = 184230, upload-time = "2026-07-09T23:01:51.764Z" }, + { url = "https://files.pythonhosted.org/packages/83/3f/06da6b5c18ae37579c09dbaff45fdb7b8483b0b9b5e6077b55e60c68cade/ckzg-2.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:5eb3b5327dd0cbaaa6551e01a42af9780e998640c57236e460830bf9a6e6f9b4", size = 107624, upload-time = "2026-07-09T23:01:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/24/d3/6d80b8a9ffca4730edc9932c9bf0652cd241dee597a72373f51b44240bc9/ckzg-2.1.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dbd7021cdc5616df5902ec04876b9af25d17facc39851b4d6630c9c2b209e30a", size = 96896, upload-time = "2026-07-09T23:01:53.99Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ce/099305aa2abd9700a376b90624ee01fe2180c0d8df2d936b9d5de6afb5bf/ckzg-2.1.8-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:5ecbcd887fa97988ddfc3ac4d1951367fa4f6bb25a6f72d449550ea4be45b938", size = 183796, upload-time = "2026-07-09T23:01:55.251Z" }, + { url = "https://files.pythonhosted.org/packages/03/1e/de72a59a34158e2057ca9522d363be62e880caa8018c98b8cd6da511dada/ckzg-2.1.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaeaed98d7be94f7c215373f90cd06536237d6ec08d48d2be74c630e03edf73c", size = 170089, upload-time = "2026-07-09T23:01:56.6Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6e/0a1fa7def67b97e3de29163cd76ef180f1976ae02c46ee5aa0bc8746eba1/ckzg-2.1.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1030903bf9989957b73fe30a96516e75a8bc27efb65e1b6e9d3507447bfee06", size = 179605, upload-time = "2026-07-09T23:01:57.783Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/f580e449ffe83d1e9281d57a7e903a2409eaaadbd1275f08c02dfa027cf7/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff48587f541b625dd0e471ecc866cf91462100a4429e3c21cf4f099e7dff9150", size = 177148, upload-time = "2026-07-09T23:01:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/fe/76/ea8cc7f7daa82a4e9cab639102759a5af44b5b6115117e6d9b7e915df3aa/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0580e8a1780d44a85c6edaa44e30a6a85565ad26593becb320d7353bc05e8627", size = 192301, upload-time = "2026-07-09T23:02:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/b6/03/a047103bdb3d7da7a4a5ecc4926b7001cfbe20bcc60c5a2676a55e8d7cf8/ckzg-2.1.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc8a259bc3cc321413c3c8eb0789cbe35919f4f4bb32946b0e1a484d4303629c", size = 186786, upload-time = "2026-07-09T23:02:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/8c4165dd469dfaecb6fb5423c0cd150a1d7977c895f8e4d1cfe51bca6b2a/ckzg-2.1.8-cp314-cp314t-win_amd64.whl", hash = "sha256:f41377a2a63330df64ae6f7cd806a288b21c52aa346151fd8f0551b9e0742289", size = 107734, upload-time = "2026-07-09T23:02:02.715Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "construct" +version = "2.10.70" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, +] + +[[package]] +name = "construct-typing" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "construct" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/ae/659fe4866d89ef5a3a65cddbdd7b35882f4feb72db383821965f2fcea934/construct_typing-0.7.0.tar.gz", hash = "sha256:71d110dedff39bd3b603c734077032a7065bc597a49db1f5b03a211d05dbac23", size = 45104, upload-time = "2025-10-27T19:30:29.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/0c/2db6f7e1ae9795e436c6a0dc0bc38b12b8c8a228cb63203e24190b755b3b/construct_typing-0.7.0-py3-none-any.whl", hash = "sha256:c92383c6e8e5d07ba25811c8d5163820458d821e73bb1006541f43f89788646c", size = 24350, upload-time = "2025-10-27T19:30:27.505Z" }, +] + +[[package]] +name = "cytoolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/d4/16916f3dc20a3f5455b63c35dcb260b3716f59ce27a93586804e70e431d5/cytoolz-1.1.0.tar.gz", hash = "sha256:13a7bf254c3c0d28b12e2290b82aed0f0977a4c2a2bf84854fcdc7796a29f3b0", size = 642510, upload-time = "2025-10-19T00:44:56.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/7a/3244e6e3587be9abfee3b1c320e43a279831b3c3a31fe5d08c1ee6193e6b/cytoolz-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:72d7043a88ea5e61ba9d17ea0d1c1eff10f645d7edfcc4e56a31ef78be287644", size = 1307813, upload-time = "2025-10-19T00:39:34.198Z" }, + { url = "https://files.pythonhosted.org/packages/32/7e/eaf504ca59addce323ef4d4ffedc2913d83c121ec19f6419bc402f7702dc/cytoolz-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d759e9ed421bacfeb456d47af8d734c057b9912b5f2441f95b27ca35e5efab07", size = 985777, upload-time = "2025-10-19T00:39:36.545Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a1/ec95443f0cf4cd0dbc574fa26ac85a0442d35f3b601a90a0e3dda077f614/cytoolz-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fdb5be8fbcc0396141189022724155a4c1c93712ac4aef8c03829af0c2a816d7", size = 982865, upload-time = "2025-10-19T00:39:38.19Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1b/8503604b0c0534977363fb77d371019395dfa031a216f9b1d8729d1280e4/cytoolz-1.1.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c8c0a513dc89bc05cc72893609118815bced5ef201f1a317b4cc3423b3a0e750", size = 2597969, upload-time = "2025-10-19T00:39:40.26Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e5/30748da06417cb2d4bc58e380b0c11d8c6539f4e289dc1e4f4b4fc248d0e/cytoolz-1.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce94db4f8ebe842c30c0ece42ff5de977c47859088c2c363dede5a68f6906484", size = 2692230, upload-time = "2025-10-19T00:39:42.327Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/e06580b74deb97dfd3513e4e6b660c2dedc220c7653f5bd3e4f772f4d885/cytoolz-1.1.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b622d4f54e370c853ded94a668f94fe72c6d70e06ac102f17a2746661c27ab52", size = 2565243, upload-time = "2025-10-19T00:39:44.403Z" }, + { url = "https://files.pythonhosted.org/packages/91/5e/79c0122a34c33afcb5aaee1fec35be24fe16cecefb9bb8890f2908feae56/cytoolz-1.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:375a65baa5a5b4ff6a0c5ff17e170cf23312e4c710755771ca966144c24216b5", size = 2868602, upload-time = "2025-10-19T00:39:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/84/404698ff02b32292db1e39cc4a2fbdabe15164b092cc364902984c3ce0f4/cytoolz-1.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c0d51bcdb3203a062a78f66bbe33db5e3123048e24a5f0e1402422d79df8ee2d", size = 2905121, upload-time = "2025-10-19T00:39:48.078Z" }, + { url = "https://files.pythonhosted.org/packages/9f/33/afad6593829ba73fc87b5ae64441e380fc937f79f24a1cda60d23cb99b8c/cytoolz-1.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1010869529bb05dc9802b6d776a34ca1b6d48b9deec70ad5e2918ae175be5c2f", size = 2684382, upload-time = "2025-10-19T00:39:49.766Z" }, + { url = "https://files.pythonhosted.org/packages/ce/86/7900013a82ca9c6cadbfb22bf50d0fbfc3b192915d2bdd9fab3f69a9afba/cytoolz-1.1.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a8f2e83295bdb33f35454d6bafcb7845b03b5881dcaed66ecbd726c7f16772", size = 2518183, upload-time = "2025-10-19T00:39:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4b/acf9be2953fed6a6d795fb66de37c367915037a998a5b3d3b69476cf91fe/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0499c5e0a8e688ed367a2e51cc13792ae8f08226c15f7d168589fc44b9b9cada", size = 2609368, upload-time = "2025-10-19T00:39:53.458Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ec/3e30455fd526f5cc37bd3dd2a0e2aafb803ae4d271e50ce53bfc30810053/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87d44e6033d4c5e95a7d39ba59b8e105ba1c29b1ccd1d215f26477cc1d64be39", size = 2561458, upload-time = "2025-10-19T00:39:55.493Z" }, + { url = "https://files.pythonhosted.org/packages/49/27/e5815c85bb18cdf95780f9596dcfd76dee910a4d635a1924648cb8a636c6/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a68cef396a7de237f7b97422a6a450dfb111722296ba217ba5b34551832f1f6e", size = 2578236, upload-time = "2025-10-19T00:39:57.512Z" }, + { url = "https://files.pythonhosted.org/packages/17/db/588e266eff397670398ea335a809152e77b02ee92e0ec42091115b42f09b/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:06ad4c95b258141f138a93ebfdc1d76ac087afc1a82f1401100a1f44b44ba656", size = 2770523, upload-time = "2025-10-19T00:39:59.194Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ad/82be0b999c7a0a0b362cedfc183eb090b872fd42937af2d6e97d58bc70f8/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ada59a4b3c59d4ac7162e0ed08667ffa78abf48e975c8a9f9d5b9bc50720f4fd", size = 2512909, upload-time = "2025-10-19T00:40:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/25/21/45f07ab0339a20c518bc9006100922babc397ab7ea5ef40a395db83b9cdd/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a8957bcaea1ba01327a9b219d2adb84144377684f51444253890dab500ca171f", size = 2755345, upload-time = "2025-10-19T00:40:03.322Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a7/e530bf2b304206f79b36d793caba1ff9448348713a41bb1ad0197714a0f2/cytoolz-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6d8cdc299d67eb0f3b9ecdafeeb55eb3b7b7470e2d950ac34b05ed4c7a5572b8", size = 2617790, upload-time = "2025-10-19T00:40:05.03Z" }, + { url = "https://files.pythonhosted.org/packages/9f/77/7f53092121d7431589344c7d65c3d43c4111547aafabb21d3ca9032d126c/cytoolz-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d8e08464c5cdea4f6df31e84b11ed6bfd79cedb99fbcbfdc15eb9361a6053c5a", size = 900209, upload-time = "2025-10-19T00:40:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/84/e4/902578658303b9bc76b1704d3ed85e6d307d311bd9fa0b919581bea56e62/cytoolz-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7e49922a7ed54262d41960bf3b835a7700327bf79cff1e9bfc73d79021132ff8", size = 944802, upload-time = "2025-10-19T00:40:08.983Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/56a7003617b4eabd8ddfb470aacc240425cbe6ddeb756adfbbaadaa175f1/cytoolz-1.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:943a662d2e72ffc4438d43ab5a1de8d852237775a423236594a3b3e381b8032c", size = 904835, upload-time = "2025-10-19T00:40:11.024Z" }, + { url = "https://files.pythonhosted.org/packages/69/82/edf1d0c32b6222f2c22e5618d6db855d44eb59f9b6f22436ff963c5d0a5c/cytoolz-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dba8e5a8c6e3c789d27b0eb5e7ce5ed7d032a7a9aae17ca4ba5147b871f6e327", size = 1314345, upload-time = "2025-10-19T00:40:13.273Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b5/0e3c1edaa26c2bd9db90cba0ac62c85bbca84224c7ae1c2e0072c4ea64c5/cytoolz-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44b31c05addb0889167a720123b3b497b28dd86f8a0aeaf3ae4ffa11e2c85d55", size = 989259, upload-time = "2025-10-19T00:40:15.196Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/e2b2ee9fc684867e817640764ea5807f9d25aa1e7bdba02dd4b249aab0f7/cytoolz-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:653cb18c4fc5d8a8cfce2bce650aabcbe82957cd0536827367d10810566d5294", size = 986551, upload-time = "2025-10-19T00:40:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/39/9f/4e8ee41acf6674f10a9c2c9117b2f219429a5a0f09bba6135f34ca4f08a6/cytoolz-1.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:853a5b4806915020c890e1ce70cc056bbc1dd8bc44f2d74d555cccfd7aefba7d", size = 2688378, upload-time = "2025-10-19T00:40:18.552Z" }, + { url = "https://files.pythonhosted.org/packages/78/94/ef006f3412bc22444d855a0fc9ecb81424237fb4e5c1a1f8f5fb79ac978f/cytoolz-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7b44e9de86bea013fe84fd8c399d6016bbb96c37c5290769e5c99460b9c53e5", size = 2798299, upload-time = "2025-10-19T00:40:20.191Z" }, + { url = "https://files.pythonhosted.org/packages/df/aa/365953926ee8b4f2e07df7200c0d73632155908c8867af14b2d19cc9f1f7/cytoolz-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:098d628a801dc142e9740126be5624eb7aef1d732bc7a5719f60a2095547b485", size = 2639311, upload-time = "2025-10-19T00:40:22.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ee/62beaaee7df208f22590ad07ef8875519af49c52ca39d99460b14a00f15a/cytoolz-1.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:779ee4096ed7a82cffab89372ffc339631c285079dbf33dbe7aff1f6174985df", size = 2979532, upload-time = "2025-10-19T00:40:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/c5/04/2211251e450bed111ada1194dc42c461da9aea441de62a01e4085ea6de9f/cytoolz-1.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f2ce18dd99533d077e9712f9faa852f389f560351b1efd2f2bdb193a95eddde2", size = 3018632, upload-time = "2025-10-19T00:40:26.175Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/4a3400e4d07d3916172bf74fede08020d7b4df01595d8a97f1e9507af5ae/cytoolz-1.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac266a34437812cf841cecbfe19f355ab9c3dd1ef231afc60415d40ff12a76e4", size = 2788579, upload-time = "2025-10-19T00:40:27.878Z" }, + { url = "https://files.pythonhosted.org/packages/fe/82/bb88caa53a41f600e7763c517d50e2efbbe6427ea395716a92b83f44882a/cytoolz-1.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1920b9b9c13d60d0bb6cd14594b3bce0870022eccb430618c37156da5f2b7a55", size = 2593024, upload-time = "2025-10-19T00:40:29.601Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/8b25e59570da16c7a0f173b8c6ec0aa6f3abd47fd385c007485acb459896/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47caa376dafd2bdc29f8a250acf59c810ec9105cd6f7680b9a9d070aae8490ec", size = 2715304, upload-time = "2025-10-19T00:40:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/faec7696f235521b926ffdf92c102f5b029f072d28e1020364e55b084820/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5ab2c97d8aaa522b038cca9187b1153347af22309e7c998b14750c6fdec7b1cb", size = 2654461, upload-time = "2025-10-19T00:40:32.884Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/f790ed167c04b8d2a33bed30770a9b7066fc4f573321d797190e5f05685f/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4bce006121b120e8b359244ee140bb0b1093908efc8b739db8dbaa3f8fb42139", size = 2672077, upload-time = "2025-10-19T00:40:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b3/80b8183e7eee44f45bfa3cdd3ebdadf3dd43ffc686f96d442a6c4dded45d/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fc0f1e4e9bb384d26e73c6657bbc26abdae4ff66a95933c00f3d578be89181b", size = 2881589, upload-time = "2025-10-19T00:40:36.315Z" }, + { url = "https://files.pythonhosted.org/packages/8f/05/ac5ba5ddb88a3ba7ecea4bf192194a838af564d22ea7a4812cbb6bd106ce/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd3f894ff972da1994d06ac6157d74e40dda19eb31fe5e9b7863ca4278c3a167", size = 2589924, upload-time = "2025-10-19T00:40:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/100483cae3849d24351c8333a815dc6adaf3f04912486e59386d86d9db9a/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0846f49cf8a4496bd42659040e68bd0484ce6af819709cae234938e039203ba0", size = 2868059, upload-time = "2025-10-19T00:40:40.025Z" }, + { url = "https://files.pythonhosted.org/packages/34/6e/3a7c56b325772d39397fc3aafb4dc054273982097178b6c3917c6dad48de/cytoolz-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:16a3af394ade1973226d64bb2f9eb3336adbdea03ed5b134c1bbec5a3b20028e", size = 2721692, upload-time = "2025-10-19T00:40:41.621Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ca/9fdaee32c3bc769dfb7e7991d9499136afccea67e423d097b8fb3c5acbc1/cytoolz-1.1.0-cp311-cp311-win32.whl", hash = "sha256:b786c9c8aeab76cc2f76011e986f7321a23a56d985b77d14f155d5e5514ea781", size = 899349, upload-time = "2025-10-19T00:40:43.183Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/2ab98edeea90311e4029e1643e43d2027b54da61453292d9ea51a103ee87/cytoolz-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebf06d1c5344fb22fee71bf664234733e55db72d74988f2ecb7294b05e4db30c", size = 945831, upload-time = "2025-10-19T00:40:44.693Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8d/777d86ea6bcc68b0fc926b0ef8ab51819e2176b37aadea072aac949d5231/cytoolz-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b63f5f025fac893393b186e132e3e242de8ee7265d0cd3f5bdd4dda93f6616c9", size = 904076, upload-time = "2025-10-19T00:40:46.678Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ec/01426224f7acf60183d3921b25e1a8e71713d3d39cb464d64ac7aace6ea6/cytoolz-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:99f8e134c9be11649342853ec8c90837af4089fc8ff1e8f9a024a57d1fa08514", size = 1327800, upload-time = "2025-10-19T00:40:48.674Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/e07e8fedd332ac9626ad58bea31416dda19bfd14310731fa38b16a97e15f/cytoolz-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6f44cf9319c30feb9a50aa513d777ef51efec16f31c404409e7deb8063df64", size = 997118, upload-time = "2025-10-19T00:40:50.919Z" }, + { url = "https://files.pythonhosted.org/packages/ab/72/c0f766d63ed2f9ea8dc8e1628d385d99b41fb834ce17ac3669e3f91e115d/cytoolz-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:945580dc158c557172fca899a35a99a16fbcebf6db0c77cb6621084bc82189f9", size = 991169, upload-time = "2025-10-19T00:40:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/df/4b/1f757353d1bf33e56a7391ecc9bc49c1e529803b93a9d2f67fe5f92906fe/cytoolz-1.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:257905ec050d04f2f856854620d1e25556fd735064cebd81b460f54939b9f9d5", size = 2700680, upload-time = "2025-10-19T00:40:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/25/73/9b25bb7ed8d419b9d6ff2ae0b3d06694de79a3f98f5169a1293ff7ad3a3f/cytoolz-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82779049f352fb3ab5e8c993ab45edbb6e02efb1f17f0b50f4972c706cc51d76", size = 2824951, upload-time = "2025-10-19T00:40:56.137Z" }, + { url = "https://files.pythonhosted.org/packages/0c/93/9c787f7c909e75670fff467f2504725d06d8c3f51d6dfe22c55a08c8ccd4/cytoolz-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d3e405e435320e08c5a1633afaf285a392e2d9cef35c925d91e2a31dfd7a688", size = 2679635, upload-time = "2025-10-19T00:40:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/9ee92c302cccf7a41a7311b325b51ebeff25d36c1f82bdc1bbe3f58dc947/cytoolz-1.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:923df8f5591e0d20543060c29909c149ab1963a7267037b39eee03a83dbc50a8", size = 2938352, upload-time = "2025-10-19T00:40:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a3/3b58c5c1692c3bacd65640d0d5c7267a7ebb76204f7507aec29de7063d2f/cytoolz-1.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:25db9e4862f22ea0ae2e56c8bec9fc9fd756b655ae13e8c7b5625d7ed1c582d4", size = 3022121, upload-time = "2025-10-19T00:41:01.209Z" }, + { url = "https://files.pythonhosted.org/packages/e1/93/c647bc3334355088c57351a536c2d4a83dd45f7de591fab383975e45bff9/cytoolz-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7a98deb11ccd8e5d9f9441ef2ff3352aab52226a2b7d04756caaa53cd612363", size = 2857656, upload-time = "2025-10-19T00:41:03.456Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c2/43fea146bf4141deea959e19dcddf268c5ed759dec5c2ed4a6941d711933/cytoolz-1.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dce4ee9fc99104bc77efdea80f32ca5a650cd653bcc8a1d984a931153d3d9b58", size = 2551284, upload-time = "2025-10-19T00:41:05.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/cdc7a81ce5cfcde7ef523143d545635fc37e80ccacce140ae58483a21da3/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80d6da158f7d20c15819701bbda1c041f0944ede2f564f5c739b1bc80a9ffb8b", size = 2721673, upload-time = "2025-10-19T00:41:07.528Z" }, + { url = "https://files.pythonhosted.org/packages/45/be/f8524bb9ad8812ad375e61238dcaa3177628234d1b908ad0b74e3657cafd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3b5c5a192abda123ad45ef716ec9082b4cf7d95e9ada8291c5c2cc5558be858b", size = 2722884, upload-time = "2025-10-19T00:41:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/23/e6/6bb8e4f9c267ad42d1ff77b6d2e4984665505afae50a216290e1d7311431/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5b399ce7d967b1cb6280250818b786be652aa8ddffd3c0bb5c48c6220d945ab5", size = 2685486, upload-time = "2025-10-19T00:41:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/88619f9c8d2b682562c0c886bbb7c35720cb83fda2ac9a41bdd14073d9bd/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7e29a1a03f00b4322196cfe8e2c38da9a6c8d573566052c586df83aacc5663c", size = 2839661, upload-time = "2025-10-19T00:41:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/4478ebf471ee78dd496d254dc0f4ad729cd8e6ba8257de4f0a98a2838ef2/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5291b117d71652a817ec164e7011f18e6a51f8a352cc9a70ed5b976c51102fda", size = 2547095, upload-time = "2025-10-19T00:41:16.054Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/f1dea33367b0b3f64e199c230a14a6b6f243c189020effafd31e970ca527/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8caef62f846a9011676c51bda9189ae394cdd6bb17f2946ecaedc23243268320", size = 2870901, upload-time = "2025-10-19T00:41:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/33591c09dfe799b8fb692cf2ad383e2c41ab6593cc960b00d1fc8a145655/cytoolz-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:de425c5a8e3be7bb3a195e19191d28d9eb3c2038046064a92edc4505033ec9cb", size = 2765422, upload-time = "2025-10-19T00:41:20.075Z" }, + { url = "https://files.pythonhosted.org/packages/60/2b/a8aa233c9416df87f004e57ae4280bd5e1f389b4943d179f01020c6ec629/cytoolz-1.1.0-cp312-cp312-win32.whl", hash = "sha256:296440a870e8d1f2e1d1edf98f60f1532b9d3ab8dfbd4b25ec08cd76311e79e5", size = 901933, upload-time = "2025-10-19T00:41:21.646Z" }, + { url = "https://files.pythonhosted.org/packages/ad/33/4c9bdf8390dc01d2617c7f11930697157164a52259b6818ddfa2f94f89f4/cytoolz-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:07156987f224c6dac59aa18fb8bf91e1412f5463961862716a3381bf429c8699", size = 947989, upload-time = "2025-10-19T00:41:23.288Z" }, + { url = "https://files.pythonhosted.org/packages/35/ac/6e2708835875f5acb52318462ed296bf94ed0cb8c7cb70e62fbd03f709e3/cytoolz-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:23e616b38f5b3160c7bb45b0f84a8f3deb4bd26b29fb2dfc716f241c738e27b8", size = 903913, upload-time = "2025-10-19T00:41:24.992Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/b3ddb3ee44fe0045e95dd973746f93f033b6f92cce1fc3cbbe24b329943c/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:76c9b58555300be6dde87a41faf1f97966d79b9a678b7a526fcff75d28ef4945", size = 976728, upload-time = "2025-10-19T00:41:26.5Z" }, + { url = "https://files.pythonhosted.org/packages/42/21/a3681434aa425875dd828bb515924b0f12c37a55c7d2bc5c0c5de3aeb0b4/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1d638b10d3144795655e9395566ce35807df09219fd7cacd9e6acbdef67946a", size = 986057, upload-time = "2025-10-19T00:41:28.911Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cb/efc1b29e211e0670a6953222afaac84dcbba5cb940b130c0e49858978040/cytoolz-1.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:26801c1a165e84786a99e03c9c9973356caaca002d66727b761fb1042878ef06", size = 992632, upload-time = "2025-10-19T00:41:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/e50621d21e939338c97faab651f58ea7fa32101226a91de79ecfb89d71e1/cytoolz-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a9a464542912d3272f6dccc5142df057c71c6a5cbd30439389a732df401afb7", size = 1317534, upload-time = "2025-10-19T00:41:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6b/25aa9739b0235a5bc4c1ea293186bc6822a4c6607acfe1422423287e7400/cytoolz-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6104fa942aa5784bf54f339563de637557e3443b105760bc4de8f16a7fc79b", size = 992336, upload-time = "2025-10-19T00:41:34.073Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/5f4deb0ff958805309d135d899c764364c1e8a632ce4994bd7c45fb98df2/cytoolz-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56161f0ab60dc4159ec343509abaf809dc88e85c7e420e354442c62e3e7cbb77", size = 986118, upload-time = "2025-10-19T00:41:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e3/f6255b76c8cc0debbe1c0779130777dc0434da6d9b28a90d9f76f8cb67cd/cytoolz-1.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:832bd36cc9123535f1945acf6921f8a2a15acc19cfe4065b1c9b985a28671886", size = 2679563, upload-time = "2025-10-19T00:41:37.926Z" }, + { url = "https://files.pythonhosted.org/packages/59/8a/acc6e39a84e930522b965586ad3a36694f9bf247b23188ee0eb47b1c9ed1/cytoolz-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1842636b6e034f229bf084c2bcdcfd36c8437e752eefd2c74ce9e2f10415cb6e", size = 2813020, upload-time = "2025-10-19T00:41:39.935Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/0083608286ad1716eda7c41f868e85ac549f6fd6b7646993109fa0bdfd98/cytoolz-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:823df012ab90d2f2a0f92fea453528539bf71ac1879e518524cd0c86aa6df7b9", size = 2669312, upload-time = "2025-10-19T00:41:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/d16080b575520fe5da00cede1ece4e0a4180ec23f88dcdc6a2f5a90a7f7f/cytoolz-1.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f1fcf9e7e7b3487883ff3f815abc35b89dcc45c4cf81c72b7ee457aa72d197b", size = 2922147, upload-time = "2025-10-19T00:41:43.252Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bc/716c9c1243701e58cad511eb3937fd550e645293c5ed1907639c5d66f194/cytoolz-1.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4cdb3fa1772116827f263f25b0cdd44c663b6701346a56411960534a06c082de", size = 2981602, upload-time = "2025-10-19T00:41:45.354Z" }, + { url = "https://files.pythonhosted.org/packages/14/bc/571b232996846b27f4ac0c957dc8bf60261e9b4d0d01c8d955e82329544e/cytoolz-1.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1b5c95041741b81430454db65183e133976f45ac3c03454cfa8147952568529", size = 2830103, upload-time = "2025-10-19T00:41:47.959Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/c594afb46ecd78e4b7e1fb92c947ed041807875661ceda73baaf61baba4f/cytoolz-1.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2079fd9f1a65f4c61e6278c8a6d4f85edf30c606df8d5b32f1add88cbbe2286", size = 2533802, upload-time = "2025-10-19T00:41:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/93/83/1edcf95832555a78fc43b975f3ebe8ceadcc9664dd47fd33747a14df5069/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a92a320d72bef1c7e2d4c6d875125cf57fc38be45feb3fac1bfa64ea401f54a4", size = 2706071, upload-time = "2025-10-19T00:41:51.386Z" }, + { url = "https://files.pythonhosted.org/packages/e2/df/035a408df87f25cfe3611557818b250126cd2281b2104cd88395de205583/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06d1c79aa51e6a92a90b0e456ebce2288f03dd6a76c7f582bfaa3eda7692e8a5", size = 2707575, upload-time = "2025-10-19T00:41:53.305Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a4/ef78e13e16e93bf695a9331321d75fbc834a088d941f1c19e6b63314e257/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e1d7be25f6971e986a52b6d3a0da28e1941850985417c35528f6823aef2cfec5", size = 2660486, upload-time = "2025-10-19T00:41:55.542Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/2c3d60682b26058d435416c4e90d4a94db854de5be944dfd069ed1be648a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:964b248edc31efc50a65e9eaa0c845718503823439d2fa5f8d2c7e974c2b5409", size = 2819605, upload-time = "2025-10-19T00:41:58.257Z" }, + { url = "https://files.pythonhosted.org/packages/45/92/19b722a1d83cc443fbc0c16e0dc376f8a451437890d3d9ee370358cf0709/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c9ff2b3c57c79b65cb5be14a18c6fd4a06d5036fb3f33e973a9f70e9ac13ca28", size = 2533559, upload-time = "2025-10-19T00:42:00.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/fa3b7891da51115204416f14192081d3dea0eaee091f123fdc1347de8dd1/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:22290b73086af600042d99f5ce52a43d4ad9872c382610413176e19fc1d4fd2d", size = 2839171, upload-time = "2025-10-19T00:42:01.881Z" }, + { url = "https://files.pythonhosted.org/packages/46/40/d3519d5cd86eebebf1e8b7174ec32dfb6ecec67b48b0cfb92bf226659b5a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ade74fccd080ea793382968913ee38d7a35c921df435bbf0a6aeecf0d17574", size = 2743379, upload-time = "2025-10-19T00:42:03.809Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/a9e7511f0a13fdbefa5bf73cf8e4763878140de9453fd3e50d6ac57b6be7/cytoolz-1.1.0-cp313-cp313-win32.whl", hash = "sha256:db5dbcfda1c00e937426cbf9bdc63c24ebbc358c3263bfcbc1ab4a88dc52aa8e", size = 900844, upload-time = "2025-10-19T00:42:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/fb7eb403c6a4c81e5a30363f34a71adcc8bf5292dc8ea32e2440aa5668f2/cytoolz-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e2d3fe3b45c3eb7233746f7aca37789be3dceec3e07dcc406d3e045ea0f7bdc", size = 946461, upload-time = "2025-10-19T00:42:07.983Z" }, + { url = "https://files.pythonhosted.org/packages/93/bb/1c8c33d353548d240bc6e8677ee8c3560ce5fa2f084e928facf7c35a6dcf/cytoolz-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:32c559f95ff44a9ebcbd934acaa1e6dc8f3e6ffce4762a79a88528064873d6d5", size = 902673, upload-time = "2025-10-19T00:42:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/4a53acc60f59030fcaf48c7766e3c4c81bd997379425aa45b129396557b5/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9e2cd93b28f667c5870a070ab2b8bb4397470a85c4b204f2454b0ad001cd1ca3", size = 1372336, upload-time = "2025-10-19T00:42:12.104Z" }, + { url = "https://files.pythonhosted.org/packages/ac/90/f28fd8ad8319d8f5c8da69a2c29b8cf52a6d2c0161602d92b366d58926ab/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f494124e141a9361f31d79875fe7ea459a3be2b9dadd90480427c0c52a0943d4", size = 1011930, upload-time = "2025-10-19T00:42:14.231Z" }, + { url = "https://files.pythonhosted.org/packages/c9/95/4561c4e0ad1c944f7673d6d916405d68080f10552cfc5d69a1cf2475a9a1/cytoolz-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53a3262bf221f19437ed544bf8c0e1980c81ac8e2a53d87a9bc075dba943d36f", size = 1020610, upload-time = "2025-10-19T00:42:15.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/14/b2e1ffa4995ec36e1372e243411ff36325e4e6d7ffa34eb4098f5357d176/cytoolz-1.1.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47663e57d3f3f124921f38055e86a1022d0844c444ede2e8f090d3bbf80deb65", size = 2917327, upload-time = "2025-10-19T00:42:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/4a/29/7cab6c609b4514ac84cca2f7dca6c509977a8fc16d27c3a50e97f105fa6a/cytoolz-1.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5a8755c4104ee4e3d5ba434c543b5f85fdee6a1f1df33d93f518294da793a60", size = 3108951, upload-time = "2025-10-19T00:42:19.363Z" }, + { url = "https://files.pythonhosted.org/packages/9a/71/1d1103b819458679277206ad07d78ca6b31c4bb88d6463fd193e19bfb270/cytoolz-1.1.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d96ff3d381423af1b105295f97de86d1db51732c9566eb37378bab6670c5010", size = 2807149, upload-time = "2025-10-19T00:42:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d4/3d83a05a21e7d2ed2b9e6daf489999c29934b005de9190272b8a2e3735d0/cytoolz-1.1.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0ec96b3d537cdf47d4e76ded199f7440715f4c71029b45445cff92c1248808c2", size = 3111608, upload-time = "2025-10-19T00:42:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/51/88/96f68354c3d4af68de41f0db4fe41a23b96a50a4a416636cea325490cfeb/cytoolz-1.1.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:208e2f2ef90a32b0acbff3303d90d89b13570a228d491d2e622a7883a3c68148", size = 3179373, upload-time = "2025-10-19T00:42:24.395Z" }, + { url = "https://files.pythonhosted.org/packages/ce/50/ed87a5cd8e6f27ffbb64c39e9730e18ec66c37631db2888ae711909f10c9/cytoolz-1.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d416a81bb0bd517558668e49d30a7475b5445f9bbafaab7dcf066f1e9adba36", size = 3003120, upload-time = "2025-10-19T00:42:26.18Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a7/acde155b050d6eaa8e9c7845c98fc5fb28501568e78e83ebbf44f8855274/cytoolz-1.1.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f32e94c91ffe49af04835ee713ebd8e005c85ebe83e7e1fdcc00f27164c2d636", size = 2703225, upload-time = "2025-10-19T00:42:27.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/9d518597c5bdea626b61101e8d2ff94124787a42259dafd9f5fc396f346a/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15d0c6405efc040499c46df44056a5c382f551a7624a41cf3e4c84a96b988a15", size = 2956033, upload-time = "2025-10-19T00:42:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/93e5f860926165538c85e1c5e1670ad3424f158df810f8ccd269da652138/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bf069c5381d757debae891401b88b3a346ba3a28ca45ba9251103b282463fad8", size = 2862950, upload-time = "2025-10-19T00:42:31.803Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/99d6af00487bedc27597b54c9fcbfd5c833a69c6b7a9b9f0fff777bfc7aa/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d5cf15892e63411ec1bd67deff0e84317d974e6ab2cdfefdd4a7cea2989df66", size = 2861757, upload-time = "2025-10-19T00:42:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/71/ca/adfa1fb7949478135a37755cb8e88c20cd6b75c22a05f1128f05f3ab2c60/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3872c21170f8341656f8692f8939e8800dcee6549ad2474d4c817bdefd62cd", size = 2979049, upload-time = "2025-10-19T00:42:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/70/4c/7bf47a03a4497d500bc73d4204e2d907771a017fa4457741b2a1d7c09319/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b9ddeff8e8fd65eb1fcefa61018100b2b627e759ea6ad275d2e2a93ffac147bf", size = 2699492, upload-time = "2025-10-19T00:42:37.133Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e7/3d034b0e4817314f07aa465d5864e9b8df9d25cb260a53dd84583e491558/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:02feeeda93e1fa3b33414eb57c2b0aefd1db8f558dd33fdfcce664a0f86056e4", size = 2995646, upload-time = "2025-10-19T00:42:38.912Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/be357181c71648d9fe1d1ce91cd42c63457dcf3c158e144416fd51dced83/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d08154ad45349162b6c37f12d5d1b2e6eef338e657b85e1621e4e6a4a69d64cb", size = 2919481, upload-time = "2025-10-19T00:42:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bf5434fde726c4f80cb99912b2d8e0afa1587557e2a2d7e0315eb942f2de/cytoolz-1.1.0-cp313-cp313t-win32.whl", hash = "sha256:10ae4718a056948d73ca3e1bb9ab1f95f897ec1e362f829b9d37cc29ab566c60", size = 951595, upload-time = "2025-10-19T00:42:42.877Z" }, + { url = "https://files.pythonhosted.org/packages/64/29/39c161e9204a9715321ddea698cbd0abc317e78522c7c642363c20589e71/cytoolz-1.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1bb77bc6197e5cb19784b6a42bb0f8427e81737a630d9d7dda62ed31733f9e6c", size = 1004445, upload-time = "2025-10-19T00:42:44.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/7cbff5e9a689f558cb0bdf277f9562b2ac51acf7cd15e055b8c3efb0e1ef/cytoolz-1.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:563dda652c6ff52d215704fbe6b491879b78d7bbbb3a9524ec8e763483cb459f", size = 926207, upload-time = "2025-10-19T00:42:46.456Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e8/297a85ba700f437c01eba962428e6ab4572f6c3e68e8ff442ce5c9d3a496/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d542cee7c7882d2a914a33dec4d3600416fb336734df979473249d4c53d207a1", size = 980613, upload-time = "2025-10-19T00:42:47.988Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d7/2b02c9d18e9cc263a0e22690f78080809f1eafe72f26b29ccc115d3bf5c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31922849b701b0f24bb62e56eb2488dcd3aa6ae3057694bd6b3b7c4c2bc27c2f", size = 990476, upload-time = "2025-10-19T00:42:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/89/26/b6b159d2929310fca0eff8a4989cd4b1ecbdf7c46fdff46c7a20fcae55c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e68308d32afd31943314735c1335e4ab5696110e96b405f6bdb8f2a8dc771a16", size = 992712, upload-time = "2025-10-19T00:42:51.306Z" }, + { url = "https://files.pythonhosted.org/packages/42/a0/f7c572aa151ed466b0fce4a327c3cc916d3ef3c82e341be59ea4b9bee9e4/cytoolz-1.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc4bb48b3b866e1867f7c6411a4229e5b44be3989060663713e10efc24c9bd5f", size = 1322596, upload-time = "2025-10-19T00:42:52.978Z" }, + { url = "https://files.pythonhosted.org/packages/72/7c/a55d035e20b77b6725e85c8f1a418b3a4c23967288b8b0c2d1a40f158cbe/cytoolz-1.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:456f77207d1445025d7ef262b8370a05492dcb1490cb428b0f3bf1bd744a89b0", size = 992825, upload-time = "2025-10-19T00:42:55.026Z" }, + { url = "https://files.pythonhosted.org/packages/03/af/39d2d3db322136e12e9336a1f13bab51eab88b386bfb11f91d3faff8ba34/cytoolz-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:174ebc71ebb20a9baeffce6ee07ee2cd913754325c93f99d767380d8317930f7", size = 990525, upload-time = "2025-10-19T00:42:56.666Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bd/65d7a869d307f9b10ad45c2c1cbb40b81a8d0ed1138fa17fd904f5c83298/cytoolz-1.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8b3604fef602bcd53415055a4f68468339192fd17be39e687ae24f476d23d56e", size = 2672409, upload-time = "2025-10-19T00:42:58.81Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fb/74dfd844bfd67e810bd36e8e3903a143035447245828e7fcd7c81351d775/cytoolz-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3604b959a01f64c366e7d10ec7634d5f5cfe10301e27a8f090f6eb3b2a628a18", size = 2808477, upload-time = "2025-10-19T00:43:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/d6/1f/587686c43e31c19241ec317da66438d093523921ea7749bbc65558a30df9/cytoolz-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6db2127a3c1bc2f59f08010d2ae53a760771a9de2f67423ad8d400e9ba4276e8", size = 2636881, upload-time = "2025-10-19T00:43:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/90468cd34f77cb38a11af52c4dc6199efcc97a486395a21bef72e9b7602e/cytoolz-1.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56584745ac647993a016a21bc76399113b7595e312f8d0a1b140c9fcf9b58a27", size = 2937315, upload-time = "2025-10-19T00:43:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/d9/50/7b92cd78c613b92e3509e6291d3fb7e0d72ebda999a8df806a96c40ca9ab/cytoolz-1.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db2c4c3a7f7bd7e03bb1a236a125c8feb86c75802f4ecda6ecfaf946610b2930", size = 2959988, upload-time = "2025-10-19T00:43:05.758Z" }, + { url = "https://files.pythonhosted.org/packages/44/d5/34b5a28a8d9bb329f984b4c2259407ca3f501d1abeb01bacea07937d85d1/cytoolz-1.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48cb8a692111a285d2b9acd16d185428176bfbffa8a7c274308525fccd01dd42", size = 2795116, upload-time = "2025-10-19T00:43:07.411Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d9/5dd829e33273ec03bdc3c812e6c3281987ae2c5c91645582f6c331544a64/cytoolz-1.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2f344ba5eb17dcf38ee37fdde726f69053f54927db8f8a1bed6ac61e5b1890d", size = 2535390, upload-time = "2025-10-19T00:43:09.104Z" }, + { url = "https://files.pythonhosted.org/packages/87/1f/7f9c58068a8eec2183110df051bc6b69dd621143f84473eeb6dc1b32905a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abf76b1c1abd031f098f293b6d90ee08bdaa45f8b5678430e331d991b82684b1", size = 2704834, upload-time = "2025-10-19T00:43:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/d2/90/667def5665333575d01a65fe3ec0ca31b897895f6e3bc1a42d6ea3659369/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ddf9a38a5b686091265ff45b53d142e44a538cd6c2e70610d3bc6be094219032", size = 2658441, upload-time = "2025-10-19T00:43:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/23/79/6615f9a14960bd29ac98b823777b6589357833f65cf1a11b5abc1587c120/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:946786755274f07bb2be0400f28adb31d7d85a7c7001873c0a8e24a503428fb3", size = 2654766, upload-time = "2025-10-19T00:43:14.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/be59c6e0ae02153ef10ae1ff0f380fb19d973c651b50cf829a731f6c9e79/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b8f78b9fed79cf185ad4ddec099abeef45951bdcb416c5835ba05f0a1242c7", size = 2827649, upload-time = "2025-10-19T00:43:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/b7/854ddcf9f9618844108677c20d48f4611b5c636956adea0f0e85e027608f/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fccde6efefdbc02e676ccb352a2ccc8a8e929f59a1c6d3d60bb78e923a49ca44", size = 2533456, upload-time = "2025-10-19T00:43:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/45/66/bfe6fbb2bdcf03c8377c8c2f542576e15f3340c905a09d78a6cb3badd39a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:717b7775313da5f51b0fbf50d865aa9c39cb241bd4cb605df3cf2246d6567397", size = 2826455, upload-time = "2025-10-19T00:43:19.561Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0c/cce4047bd927e95f59e73319c02c9bc86bd3d76392e0eb9e41a1147a479c/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5158744a09d0e0e4a4f82225e3a3c4ebf38f9ae74467aaa905467270e52f2794", size = 2714897, upload-time = "2025-10-19T00:43:21.291Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9a/061323bb289b565802bad14fb7ab59fcd8713105df142bcf4dd9ff64f8ac/cytoolz-1.1.0-cp314-cp314-win32.whl", hash = "sha256:1ed534bdbbf063b2bb28fca7d0f6723a3e5a72b086e7c7fe6d74ae8c3e4d00e2", size = 901490, upload-time = "2025-10-19T00:43:22.895Z" }, + { url = "https://files.pythonhosted.org/packages/a3/20/1f3a733d710d2a25d6f10b463bef55ada52fe6392a5d233c8d770191f48a/cytoolz-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:472c1c9a085f5ad973ec0ad7f0b9ba0969faea6f96c9e397f6293d386f3a25ec", size = 946730, upload-time = "2025-10-19T00:43:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/2d657db4a5d1c10a152061800f812caba9ef20d7bd2406f51a5fd800c180/cytoolz-1.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7ad7ca3386fa86bd301be3fa36e7f0acb024f412f665937955acfc8eb42deff", size = 905722, upload-time = "2025-10-19T00:43:26.439Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/b4a8c76796a9a8b9bc90c7992840fa1589a1af8e0426562dea4ce9b384a7/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:64b63ed4b71b1ba813300ad0f06b8aff19a12cf51116e0e4f1ed837cea4debcf", size = 1372606, upload-time = "2025-10-19T00:43:28.491Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/a1bb1a32b454a2d650db8374ff3bf875ba0fc1c36e6446ec02a83b9140a1/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60ba6f2ed9eb0003a737e1ee1e9fa2258e749da6477946008d4324efa25149f", size = 1012189, upload-time = "2025-10-19T00:43:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/2f5cbbd81588918ee7dd70cffb66731608f578a9b72166aafa991071af7d/cytoolz-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1aa58e2434d732241f7f051e6f17657e969a89971025e24578b5cbc6f1346485", size = 1020624, upload-time = "2025-10-19T00:43:31.712Z" }, + { url = "https://files.pythonhosted.org/packages/f5/99/c4954dd86cd593cd776a038b36795a259b8b5c12cbab6363edf5f6d9c909/cytoolz-1.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6965af3fc7214645970e312deb9bd35a213a1eaabcfef4f39115e60bf2f76867", size = 2917016, upload-time = "2025-10-19T00:43:33.531Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/f1f70a17e272b433232bc8a27df97e46b202d6cc07e3b0d63f7f41ba0f2d/cytoolz-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddd2863f321d67527d3b67a93000a378ad6f967056f68c06467fe011278a6d0e", size = 3107634, upload-time = "2025-10-19T00:43:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bd/c3226a57474b4aef1f90040510cba30d0decd3515fed48dc229b37c2f898/cytoolz-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4e6b428e9eb5126053c2ae0efa62512ff4b38ed3951f4d0888ca7005d63e56f5", size = 2806221, upload-time = "2025-10-19T00:43:37.707Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/2f7bfe4aaa1e07dc9828bea228ed744faf73b26aee0c1bdf3b5520bf1909/cytoolz-1.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d758e5ef311d2671e0ae8c214c52e44617cf1e58bef8f022b547b9802a5a7f30", size = 3107671, upload-time = "2025-10-19T00:43:39.401Z" }, + { url = "https://files.pythonhosted.org/packages/4d/12/6ff3b04fbd1369d0fcd5f8b5910ba6e427e33bf113754c4c35ec3f747924/cytoolz-1.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a95416eca473e6c1179b48d86adcf528b59c63ce78f4cb9934f2e413afa9b56b", size = 3176350, upload-time = "2025-10-19T00:43:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/6691d986b728e77b5d2872743ebcd962d37a2d0f7e9ad95a81b284fbf905/cytoolz-1.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36c8ede93525cf11e2cc787b7156e5cecd7340193ef800b816a16f1404a8dc6d", size = 3001173, upload-time = "2025-10-19T00:43:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cb/f59d83a5058e1198db5a1f04e4a124c94d60390e4fa89b6d2e38ee8288a0/cytoolz-1.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c949755b6d8a649c5fbc888bc30915926f1b09fe42fea9f289e297c2f6ddd3", size = 2701374, upload-time = "2025-10-19T00:43:44.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1ae6d28df503b0bdae094879da2072b8ba13db5919cd3798918761578411/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1b6d37545816905a76d9ed59fa4e332f929e879f062a39ea0f6f620405cdc27", size = 2953081, upload-time = "2025-10-19T00:43:47.103Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/d86fe811c6222dc32d3e08f5d88d2be598a6055b4d0590e7c1428d55c386/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05332112d4087904842b36954cd1d3fc0e463a2f4a7ef9477bd241427c593c3b", size = 2862228, upload-time = "2025-10-19T00:43:49.353Z" }, + { url = "https://files.pythonhosted.org/packages/ae/32/978ef6f42623be44a0a03ae9de875ab54aa26c7e38c5c4cd505460b0927d/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:31538ca2fad2d688cbd962ccc3f1da847329e2258a52940f10a2ac0719e526be", size = 2861971, upload-time = "2025-10-19T00:43:51.028Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/74c69497e756b752b359925d1feef68b91df024a4124a823740f675dacd3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:747562aa70abf219ea16f07d50ac0157db856d447f7f498f592e097cbc77df0b", size = 2975304, upload-time = "2025-10-19T00:43:52.99Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2b/3ce0e6889a6491f3418ad4d84ae407b8456b02169a5a1f87990dbba7433b/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc15c48b20c0f467e15e341e102896c8422dccf8efc6322def5c1b02f074629", size = 2697371, upload-time = "2025-10-19T00:43:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/c616577f0891d97860643c845f7221e95240aa589586de727e28a5eb6e52/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3c03137ee6103ba92d5d6ad6a510e86fded69cd67050bd8a1843f15283be17ac", size = 2992436, upload-time = "2025-10-19T00:43:57.253Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9f/490c81bffb3428ab1fa114051fbb5ba18aaa2e2fe4da5bf4170ca524e6b3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be8e298d88f88bd172b59912240558be3b7a04959375646e7fd4996401452941", size = 2917612, upload-time = "2025-10-19T00:43:59.423Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/0fec2769660ca6472bbf3317ab634675827bb706d193e3240aaf20eab961/cytoolz-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3d407140f5604a89578285d4aac7b18b8eafa055cf776e781aabb89c48738fad", size = 960842, upload-time = "2025-10-19T00:44:01.143Z" }, + { url = "https://files.pythonhosted.org/packages/46/b4/b7ce3d3cd20337becfec978ecfa6d0ef64884d0cf32d44edfed8700914b9/cytoolz-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56e5afb69eb6e1b3ffc34716ee5f92ffbdb5cb003b3a5ca4d4b0fe700e217162", size = 1020835, upload-time = "2025-10-19T00:44:03.246Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1f/0498009aa563a9c5d04f520aadc6e1c0942434d089d0b2f51ea986470f55/cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e", size = 927963, upload-time = "2025-10-19T00:44:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/0522207170294cf691112a93c70a8ef942f60fa9ff8e793b63b1f09cedc0/cytoolz-1.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f32e93a55681d782fc6af939f6df36509d65122423cbc930be39b141064adff8", size = 922014, upload-time = "2025-10-19T00:44:44.911Z" }, + { url = "https://files.pythonhosted.org/packages/4c/49/9be2d24adaa18fa307ff14e3e43f02b2ae4b69c4ce51cee6889eb2114990/cytoolz-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5d9bc596751cbda8073e65be02ca11706f00029768fbbbc81e11a8c290bb41aa", size = 918134, upload-time = "2025-10-19T00:44:47.122Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b3/6a76c3b94c6c87c72ea822e7e67405be6b649c2e37778eeac7c0c0c69de8/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b16660d01c3931951fab49db422c627897c38c1a1f0393a97582004019a4887", size = 981970, upload-time = "2025-10-19T00:44:48.906Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8a/606e4c7ed14aa6a86aee6ca84a2cb804754dc6c4905b8f94e09e49f1ce60/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b7de5718e2113d4efccea3f06055758cdbc17388ecc3341ba4d1d812837d7c1a", size = 978877, upload-time = "2025-10-19T00:44:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/ad474dcb1f6c1ebfdda3c2ad2edbb1af122a0e79c9ff2cb901ffb5f59662/cytoolz-1.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a12a2a1a6bc44099491c05a12039efa08cc33a3d0f8c7b0566185e085e139283", size = 964279, upload-time = "2025-10-19T00:44:52.476Z" }, + { url = "https://files.pythonhosted.org/packages/68/8c/d245fd416c69d27d51f14d5ad62acc4ee5971088ee31c40ffe1cc109af68/cytoolz-1.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:047defa7f5f9a32f82373dbc3957289562e8a3fa58ae02ec8e4dca4f43a33a21", size = 916630, upload-time = "2025-10-19T00:44:54.059Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "eth-abi" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "parsimonious" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/71/d9e1380bd77fd22f98b534699af564f189b56d539cc2b9dab908d4e4c242/eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0", size = 49797, upload-time = "2025-01-14T16:29:34.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/b4/2f3982c4cbcbf5eeb6aec62df1533c0e63c653b3021ff338d44944405676/eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877", size = 28511, upload-time = "2025-01-14T16:29:31.862Z" }, +] + +[[package]] +name = "eth-account" +version = "0.13.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "ckzg" }, + { name = "eth-abi" }, + { name = "eth-keyfile" }, + { name = "eth-keys" }, + { name = "eth-rlp" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "rlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/cf/20f76a29be97339c969fd765f1237154286a565a1d61be98e76bb7af946a/eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46", size = 935998, upload-time = "2025-04-21T21:11:21.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/18/088fb250018cbe665bc2111974301b2d59f294a565aff7564c4df6878da2/eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24", size = 587452, upload-time = "2025-04-21T21:11:18.346Z" }, +] + +[[package]] +name = "eth-hash" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/c67fc24f2f676aa9b7ab29679d44f113f314c817207cd4319353356f62da/eth_hash-0.8.0.tar.gz", hash = "sha256:b009752b620da2e9c7668014849d1f5fadbe4f138603f1871cc5d4ca706896b1", size = 12225, upload-time = "2026-03-25T16:36:55.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/87/b36792150ca0b28e4df683a34be15a61461ca0e349e5b5cf3ec8f694edb9/eth_hash-0.8.0-py3-none-any.whl", hash = "sha256:523718a51b369ab89866b929a5c93c52978cd866ea309192ad980dd8271f9fac", size = 7965, upload-time = "2026-03-25T16:36:54.205Z" }, +] + +[package.optional-dependencies] +pycryptodome = [ + { name = "pycryptodome" }, +] + +[[package]] +name = "eth-keyfile" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-keys" }, + { name = "eth-utils" }, + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/66/dd823b1537befefbbff602e2ada88f1477c5b40ec3731e3d9bc676c5f716/eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1", size = 12267, upload-time = "2024-04-23T20:28:53.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fc/48a586175f847dd9e05e5b8994d2fe8336098781ec2e9836a2ad94280281/eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64", size = 7510, upload-time = "2024-04-23T20:28:51.063Z" }, +] + +[[package]] +name = "eth-keys" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/58/f54660cffe3f39aad2d80d13b072973ee9134b6cdfd8b4d086419eda997b/eth_keys-0.8.0.tar.gz", hash = "sha256:11549b251876fccd7caedd6905e494ea2309aec352ec2579b00ef9978017a964", size = 31311, upload-time = "2026-08-21T17:01:21.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/f5/24806598664a2fbc65d5ed4f0e9e986e7f9061e3b43738412c8df7ba1cbd/eth_keys-0.8.0-py3-none-any.whl", hash = "sha256:a7b94222638cccbdf2b5dae5c365d883a96826d82bb0faeb56baa65375f514ae", size = 20461, upload-time = "2026-08-21T17:01:20.767Z" }, +] + +[[package]] +name = "eth-rlp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "rlp" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ea/ad39d001fa9fed07fad66edb00af701e29b48be0ed44a3bcf58cb3adf130/eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d", size = 7720, upload-time = "2025-02-04T21:51:08.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3b/57efe2bc2df0980680d57c01a36516cd3171d2319ceb30e675de19fc2cc5/eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47", size = 4446, upload-time = "2025-02-04T21:51:05.823Z" }, +] + +[[package]] +name = "eth-typing" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/e7/06c5af99ad40494f6d10126a9030ff4eb14c5b773f2a4076017efb0a163a/eth_typing-6.0.0.tar.gz", hash = "sha256:315dd460dc0b71c15a6cd51e3c0b70d237eec8771beb844144f3a1fb4adb2392", size = 21852, upload-time = "2026-03-25T16:41:57.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0d/e756622fab29f404d846d7464f929d642a7ee6eff5b38bcc79e7c64ac630/eth_typing-6.0.0-py3-none-any.whl", hash = "sha256:ee74fb641eb36dd885e1c42c2a3055314efa532b3e71480816df70a94d35cfb9", size = 19191, upload-time = "2026-03-25T16:41:55.544Z" }, +] + +[[package]] +name = "eth-utils" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cytoolz", marker = "implementation_name == 'cpython'" }, + { name = "eth-hash" }, + { name = "eth-typing" }, + { name = "pydantic" }, + { name = "toolz", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/1b/0b8548da7b31eba87ed58bca1d0de5dcb13a6c113e02c09019ec5a6716ed/eth_utils-6.0.0.tar.gz", hash = "sha256:eb54b2f82dd300d3142c49a89da195e823f5e5284d43203593f87c67bad92a96", size = 123457, upload-time = "2026-03-25T17:11:51.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/45/a20b907227b9d1aea2e36f7b12818d055629ca9bc65fc282b45738f28ca3/eth_utils-6.0.0-py3-none-any.whl", hash = "sha256:63cf48ee32c45541cb5748751909a8345c470432fb6f0fed4bd7c53fd6400469", size = 102473, upload-time = "2026-03-25T17:11:49.953Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/63aaf9913f455e39a7027c27140edd887a87d47d65ac43532d77a51718e5/fastapi_cloud_cli-0.23.0.tar.gz", hash = "sha256:840895bb8d14309aeffc905e0dcd1334d18c6f5da54b735413a8f1cb385e581e", size = 95295, upload-time = "2026-07-28T14:03:33.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/96/7e9aba6fabce3cb05f320abeef5b81efd5134823ae85d1a517872cb83cbc/fastapi_cloud_cli-0.23.0-py3-none-any.whl", hash = "sha256:1cd2ffa56e92e92c1fc63acc426c214dd928cbeed2a4c7c6a9a5fc85ea73de16", size = 78058, upload-time = "2026-07-28T14:03:34.386Z" }, +] + +[[package]] +name = "fastar" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/52/5bee9a672f418008d34c708d66e89e8f6fed8f0812f406a1c92fb5e393a8/fastar-0.12.0.tar.gz", hash = "sha256:bba71522eae6a7627a5514ffdd4ac9645ef27d82e23931d79fd974bb49c3f2ad", size = 89647, upload-time = "2026-08-20T09:11:25.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c2/3b7dccb2a49a9778c88007c6188c10f765e0651969ca75fa30ea0648f5b3/fastar-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:310c99a33f83f83f49917cee153cdd49ac0a3e42f78ad685a84a700ede3aece5", size = 697030, upload-time = "2026-08-20T09:09:48.974Z" }, + { url = "https://files.pythonhosted.org/packages/32/fe/339338102ce9c9e9be639795ecb89ed82039bccfc53550908c99518509b2/fastar-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a57a4b3ea38937f95c8ac76432992dc29f672b265e021a4c8c7191a767188297", size = 630583, upload-time = "2026-08-20T09:09:31.519Z" }, + { url = "https://files.pythonhosted.org/packages/5c/54/bb85d625b1d338e813f5cd3eb21e7a9b1fa06ebb0cc3b9612dc20090425d/fastar-0.12.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:00104297bee6bec0c20aae27e82e62b33db4329575e4cfd3e824745128b9933a", size = 861090, upload-time = "2026-08-20T09:08:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/51c878c492281790ae02d813d344cb102fbe41c0cb5d5b639ec6def4394a/fastar-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12484b5f87529e9c0af4299a5db779c92c0c393b95867977e3ea02d22024dc96", size = 757376, upload-time = "2026-08-20T09:07:26.193Z" }, + { url = "https://files.pythonhosted.org/packages/91/70/97ac11cff14f5f860878fd9c7cb496e42c1d08683b4f231b0cd041448ef4/fastar-0.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e54319e21ea309e8f2141ed88ace0cb34af72bbd5865eb6a819773a170230e27", size = 751409, upload-time = "2026-08-20T09:07:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/6896d3ce6c6a746adba380c872ec13c7bce8f70dbca3f23de3c7f8c40a04/fastar-0.12.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2eef75f01b874968ac340be98d44a583ded3b50f29483fd3ec5fd86cce41db7d", size = 920895, upload-time = "2026-08-20T09:08:02.036Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/2ea54cb6c04586e3e1521410fbe2bb8fcc3c50bc003ad7d315587dfd64bc/fastar-0.12.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69391964bbf1b170eeed382fd05d490ba8f6b00fb0b72627ba00cf7d037b9d75", size = 810883, upload-time = "2026-08-20T09:08:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/0f7f70bb42f09f95fbe17baf88f5a0edfbf15bb6d0aeac3169adab279da8/fastar-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7a28985d86a3d8582d6ce69d356fc498035d717938b5029df057d599718888", size = 811475, upload-time = "2026-08-20T09:09:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/66/6d/b2fbc33996a28834371fb17c66a6288b3a3e5807c3504043fdb996667dc0/fastar-0.12.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3060521ae526301acadd84fbc3bdf50faa63e3f06f7faecdb286c7e4e4d8b22d", size = 877823, upload-time = "2026-08-20T09:08:20.85Z" }, + { url = "https://files.pythonhosted.org/packages/66/99/b9957de723e68941a8c0be7a9e7cdd9f7973351930448a78d2d36f5f8bfc/fastar-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:868259b82a03d90eb7b618478a5f5721f293f477b9a1f2037546e9edec21792a", size = 968491, upload-time = "2026-08-20T09:10:06.995Z" }, + { url = "https://files.pythonhosted.org/packages/7e/28/1fb25d1c0013850f5149b3c8d7007cdfdb8f1ea81b378b5dd52941427b3f/fastar-0.12.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:09e6a5d4b111c5d40f7dc0813357260ee060b74b4e14bfdc66f5c3ae37f9832c", size = 1027845, upload-time = "2026-08-20T09:10:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f6/c83f6fc45c137be8d694abd3cc8995dea1aa47e40fc0f7f8df7a622aff48/fastar-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:682a8c91d3ee82179e44ffd7673f4a6a08e7465970b75d19c38df41c39e2dbc2", size = 1069307, upload-time = "2026-08-20T09:10:46.101Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/780fd8a74a308b7ec80952d408f3b9c895a5de7eab879052b93d52c1eeff/fastar-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a30eced7e4c711a272336e8c6214ca23f30eaf57b06c85eb6f67cbf50e207b7", size = 1022014, upload-time = "2026-08-20T09:11:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/10421239278ee700fd5601cb3362dfc9873d661977a0d65c0e366df028f8/fastar-0.12.0-cp310-cp310-win32.whl", hash = "sha256:f5e9cec09cf46ba60ae78bdaf22548b6128d4142c274632faf6c8d2a7a6abf0f", size = 452365, upload-time = "2026-08-20T09:11:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/66/e4/d1dc08c8a508bea5c76c1e670d99f6c41304e60a4b27e7910a75779d05f2/fastar-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:46feb733e23f2a85f097f5bab710e49ad3e4d9ad63d6a6c5b4c6d470239c6217", size = 485137, upload-time = "2026-08-20T09:11:38.504Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/5cad0f74095e961708da3b8924bb0e51a9aefba1a05c7e1cf55496957159/fastar-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:474fa88aab2aa2ea32682edfe46cd4d24a5f75acaa2021b40d1e247e33e21b7f", size = 697887, upload-time = "2026-08-20T09:09:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/94922f85e83a283d8d56ca86c288a9cdd66da230eddc3d85485abacf7ec8/fastar-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dc6149218fa5e34526714aefabc148b25fc7d27c2f25c8b942f1567875511a9", size = 630497, upload-time = "2026-08-20T09:09:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d3/0e52f06e11d6c68f40df635f326ebe23d4f995e2d782b0ab4795093a9277/fastar-0.12.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0e16b0d029411989b9459adc65b9b553a7051909a96e4432e4a151490e42f0de", size = 860832, upload-time = "2026-08-20T09:08:58.009Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/53ccb90c01d78f58cf95f48e7caed8af130760c24edd8da90d1717802d04/fastar-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f0432d1138e7f3b65a3afc324ac373706751d1b8554541fe33d5e62ab08ff11", size = 758564, upload-time = "2026-08-20T09:07:27.766Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f3/f0532adc2833d93ba1f3bdca7dbf70f8408495e5b70168278e225274bf15/fastar-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:930d355b98304984b70b30b8a678c782ecfa958a9c710134c71ea54db995a21f", size = 751264, upload-time = "2026-08-20T09:07:45.517Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e7/4dd058aa4a2af76d7b2e789969aa0ad099440b7880ed48e6f9eae613c117/fastar-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e33b9b67d9fbcda49559c653390698a3e4d89c4d9cb9974fbbdd8a8aa0bfe3b5", size = 921701, upload-time = "2026-08-20T09:08:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/a3/89/ea4e3a8d7200b76a4a7345ed12997b1998041b5240216eef03da8c2866e0/fastar-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc98215d7cdd32b70d2ab8123448071f33e5cc4a4af2ddef67af37beb420dae2", size = 812029, upload-time = "2026-08-20T09:08:39.886Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4e/be067dce9da50815e1911684639d8404cd4d49352c9de1f9deb92417e574/fastar-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:471a2f3c7295c296252463ae2eadd87f258dec33a7fec053a553c90930e03392", size = 812845, upload-time = "2026-08-20T09:09:16.122Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1b/d96a355dc73fada5aec0baa7da4ca71320b6c709f28f1c63341de8efcc94/fastar-0.12.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:6bc9c294f2aca5590b8188ab09e04099b727444cee7e56bd89d73c57adcdb2b8", size = 878366, upload-time = "2026-08-20T09:08:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/600103a3bbf119b9b9eee64d374e5a25662022a24cd85ad12b62a4f2816b/fastar-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1c2926264f000e88e214a23c1a308a4851325ee72569c27b4188d8d44262fa56", size = 968954, upload-time = "2026-08-20T09:10:08.688Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/440a0c31ec32d8e80d3e68d6e44ea58679b6436d3454be9dd2e03f499874/fastar-0.12.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:adfc7981f3b3aaafcebc555437e66a37bafbe48d4e3e2129b782b52ed96b6c06", size = 1028641, upload-time = "2026-08-20T09:10:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/5c68b5c11d60c4899c9df25a6f87ab606b83f9f29330488ffa9259aa33ef/fastar-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:75ea567751a7eebbc5cee86e3df5f0e699afe984e6b76eed91fc304485b0808a", size = 1068536, upload-time = "2026-08-20T09:10:48.17Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fa/49137c676db7b13195d5990e2fe2158ac3b4c08cb2da65a8de6974f571a5/fastar-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c19e0d943e68ee9748610e510639e438c07d4dda13743cbb2d928689aad15599", size = 1023027, upload-time = "2026-08-20T09:11:08.032Z" }, + { url = "https://files.pythonhosted.org/packages/06/d2/95bddc1eff0dbdf63b7a577a7165413a77fd27f43013e0fdad5cc80a6987/fastar-0.12.0-cp311-cp311-win32.whl", hash = "sha256:423d2b0ff5a43405c9776f1ef0779502c547bdb2d7fa7857b4f7434c95035556", size = 452311, upload-time = "2026-08-20T09:11:57.614Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/b6233c28684745098e379b2921b160765cebd8e5771f471f5754907f9fae/fastar-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2fcbdc9b49fb240965941a21fd45ac9f7f75bf87acc33106e1ee70ab912cfdd", size = 485169, upload-time = "2026-08-20T09:11:40.097Z" }, + { url = "https://files.pythonhosted.org/packages/65/69/ed531b78cf08c0be4541f6ff0759459c4b3c186ac5ac3c4e07a7795e1a65/fastar-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:e8fe9f8e8b6b4df05f1a3561ff9a5348553b93fa6fabc7e31334d1ee720c00af", size = 461153, upload-time = "2026-08-20T09:11:26.632Z" }, + { url = "https://files.pythonhosted.org/packages/52/25/24eb7918c62a3053d1b7ac7c79262ddb50f942f599da263be865eaca9b8f/fastar-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d7a37d672233031bd41b000852e2520d34a1dd362c2e3b9000b6400a16b3bba4", size = 697081, upload-time = "2026-08-20T09:09:52.043Z" }, + { url = "https://files.pythonhosted.org/packages/23/03/856c091b035b9672c435c3f71e25d3fb5b9151693ca434f8804865bc3a93/fastar-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:787e3c8018848b56b7e47918f9a9ec0e13a68e39af6e4d386cf083b2973d254b", size = 624788, upload-time = "2026-08-20T09:09:34.712Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/649c243ca97cbc2a36e9f650ff37d4491c444b580578fa54f2e6452cfdb8/fastar-0.12.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8fa10e692ca23aafb88ab441bb280724328da7b8cebddf3296c751b4e06324ed", size = 855861, upload-time = "2026-08-20T09:08:59.762Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/0cbb9f59d598ff312f3781f8e2cc69101c2a843427c965df8dfe43436d51/fastar-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76115050e8f076fb96f1aa7662dc502b5232d21b610aec08035957a93a830cb1", size = 753839, upload-time = "2026-08-20T09:07:29.507Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fb/54a3b1a4afb643157d16b518dc5c7d6b34d9715a7cbb055d7022c456e8a7/fastar-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0df33b22848b96beb8b229869abcc6592592d654f25b6cb1856c08717aaf1544", size = 748226, upload-time = "2026-08-20T09:07:47.228Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/464ab962c9e0ead4e1cd48ede8805d07ea7ac11f3830673a9377def041b6/fastar-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27142e096eb5765b4dca66e4dbd29a9c61a0a4bc4c0ca649f6e661475da2bf8d", size = 916573, upload-time = "2026-08-20T09:08:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/b14461a0edf407bf4b1be5674870798dad3f36196ac1e95d5ebfc2210019/fastar-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eaf9306c98e22aa7650c6ec02522d77b61c539bc81583897ca351de125ade14", size = 807627, upload-time = "2026-08-20T09:08:41.384Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dc/a58f3c205d2934453ba643d0064d2735f359f736af2d15bc00dbf774e4d4/fastar-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deb0b3882626832361dd0fcee1f4ef1c6e2a7aaa4165e51c86ae0bab95caccd6", size = 808091, upload-time = "2026-08-20T09:09:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ae/f55472d62e0185f9850b536947ec0772e8dad0dbe675ff99d814a9eab629/fastar-0.12.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:47739deed9e4ccf6514821d1c7a79c9188e4a5029d80b618078739d91e7ed9f0", size = 874371, upload-time = "2026-08-20T09:08:23.917Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c8/a2156f8b274f241f8290e0fee56c3ad82f694b1776ea5557e4871a262824/fastar-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ec2100cea7274ec4b1ec77cd5613318a2d9fc9f7a291a6acf642a26e3856a93", size = 964805, upload-time = "2026-08-20T09:10:10.332Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/05913a98ba24c843c0c1765e48cc81523bf24f19db928d0af9da4ea23f3f/fastar-0.12.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f95a59a11ee59701a6c0048c90601063894102343c4bb09462eed5df0fab0077", size = 1024567, upload-time = "2026-08-20T09:10:30.236Z" }, + { url = "https://files.pythonhosted.org/packages/60/09/7a8ca1592f758c8e63e10c4cdb948a19c972fc16e136804b60a7aa68c978/fastar-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8383404eebbff9832652ddcd32f91bb4acd8e038b27597dc12aef33c4e72f7b7", size = 1062271, upload-time = "2026-08-20T09:10:49.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/5f60c801b1cbb8bf18f1d33d8ebe7af6b7b2eaf966fce02d157391cdb726/fastar-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60de8b81a04cda78e5281d72ff6d0c50e4925621cc5eb55d99c2e344d1b291f4", size = 1018508, upload-time = "2026-08-20T09:11:09.771Z" }, + { url = "https://files.pythonhosted.org/packages/28/90/c8e6a51fffac4d4c6db21354b0a36c00b24d11ca2dee896185be7d15fef3/fastar-0.12.0-cp312-cp312-win32.whl", hash = "sha256:f62411993976d5c5a0d1090f8c809f96e0671d824ee0d844e5b5256bf31fe7a9", size = 450557, upload-time = "2026-08-20T09:11:59.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/453fc6e1de62a7e79a2f636424cbc46a38297a2fd172f2b24349ec8be620/fastar-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ed84522ddffb4c41c247f3e6823c763faf45af2ba1bf887eaab7f907e74e0bbe", size = 483015, upload-time = "2026-08-20T09:11:41.669Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/f5742b59178f8767ffc8913b584310784d0b6db4f0fe696f19b44cbd45c0/fastar-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:c03a61a149eaa857a4af8bf6c0bc0895bf668b8d5685130337a1ca42e60f9828", size = 458770, upload-time = "2026-08-20T09:11:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/93/ee/bfce95bdf2bd61a1e311c7181e0ff99c39a6eebe4ca2bfd2d04eb403970b/fastar-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:654165090cdcac7ff13d43ee4012c366f0f2061ddf46658bc0ad248c8aa3960a", size = 697138, upload-time = "2026-08-20T09:09:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/87/b0/dd24d87b58b4e99257ff0b0a53b60c89b0a32e89bd33c1f966e9bda58ad3/fastar-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:685d3d45943b43c32c71c8470552a615c90e06ca532db1b7a5633f01aa108f0d", size = 623873, upload-time = "2026-08-20T09:09:36.361Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/abacbfa2e94c1ff4b717e07ae2e5521ba84804884ff51f18f01706d024cf/fastar-0.12.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f8da75b5eca0d3b540e50ab6b7dfe4b148d7ae8a0b444a9781fd1219392859a0", size = 855719, upload-time = "2026-08-20T09:09:01.612Z" }, + { url = "https://files.pythonhosted.org/packages/8a/27/2781690ebabbce0d2a25b9efd359a13a5df2b0098120167e58b013c9f65a/fastar-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13c2df8db1b7a4d783429ffa1125c53f7dd9534baae7218eb49273797691e2a8", size = 754159, upload-time = "2026-08-20T09:07:31.365Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/1e6262fe31b2e8efc90feacf3f2213c9d2dff5fcb6e88f9084f26f18d62e/fastar-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:042fd43c4e0c3f3ba3f7b8a083694d1c4bd77d60ce266090d4eb96cb8a8021c2", size = 748002, upload-time = "2026-08-20T09:07:48.724Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8e/b4792568d3e544b4e2b00b918744e4edb7e8c6be3c4fce268514febeb844/fastar-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6958a332c55052dd8090b03de238ca59190d625f4ba9c292b34e938ac64105ea", size = 915919, upload-time = "2026-08-20T09:08:07.102Z" }, + { url = "https://files.pythonhosted.org/packages/03/3f/0460223969f5dae9a49d09a29a93d5ccc359e701b0a18c0972a0ebce29b1/fastar-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b05c0c739c43b9228e57fc59eb68c38660c62030bcdea3a032b269df71f5bb8", size = 807788, upload-time = "2026-08-20T09:08:42.967Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/68e681a12232ca2dc46d7d6de0c0fbe770137a204e4a3c0b5864a9548d67/fastar-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2bfad69679111e4567d4bad41fd795071c9335cd94bc0f26e24b7d19e95c9b1", size = 807587, upload-time = "2026-08-20T09:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/81/8f/f93e981114034eb901d301690ad8e21edca6a2307b380d5e20e4e4862c7d/fastar-0.12.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:1155e1dd9c60cf636b6b3d35edfe242348f47286724fa84b5b4055c03d7fdbf6", size = 874095, upload-time = "2026-08-20T09:08:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8e/74671e6cc6d9056347806ed9eefd0ea0bda25292848b32cc8cc321f956d8/fastar-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:29b87474b2e7c9e64549b87aeb2c1d68a94e78c887a3a8d88bf7b804ddbcc0dc", size = 965017, upload-time = "2026-08-20T09:10:12.048Z" }, + { url = "https://files.pythonhosted.org/packages/7f/55/44d5c532bfdbff48f3f6ce957744a612f7a169af9e4a673f00e21f8c83ed/fastar-0.12.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4587a08d6de2e62611278fc4cd36186a3ebbf6609d9e49df34d79a407d66f599", size = 1024377, upload-time = "2026-08-20T09:10:31.936Z" }, + { url = "https://files.pythonhosted.org/packages/a8/35/73d04733a06175211fef985f4e89604882b849cf1125c4a792ef022aa85c/fastar-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e7d7512b5c747edfce129448a72c8f6223323748ca3e98cc54401241bff70ee5", size = 1061994, upload-time = "2026-08-20T09:10:52.014Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/c841b941fad02b5fc6e2277fc8ca35d963685fafc9335b76925bd166d11a/fastar-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ce9a7e7757074a3d0920a8bc3936cf4164f63786d8e1b64e425d2957ddcceace", size = 1018409, upload-time = "2026-08-20T09:11:11.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c8/59a1791df4f128e6f8fffa03fd46d9b6c8ca24bcb6b7e9071973648edab3/fastar-0.12.0-cp313-cp313-win32.whl", hash = "sha256:61f1eec258b328182c6b6258641d33264ac3080fb7fcbf40ea1e326fc855d917", size = 450302, upload-time = "2026-08-20T09:12:00.704Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9e/483982c1e60e3d9332c3b870230b34404b5b44340d647a97fe8f91d4fa0f/fastar-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:84caa362865cac75807c51afbeff2e9b313fc45f89e0865f7c8bf627ea721f4b", size = 482104, upload-time = "2026-08-20T09:11:43.655Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e6/ec8ab1d44d73c0cad4f9e0ae4ce7b9330f2d504f835774802845dd465a42/fastar-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:a3de985d942247fa924e185ff2744a6da0005dcedbb39ae1b811bec11710e572", size = 458292, upload-time = "2026-08-20T09:11:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f9/cf4b63a3b8bfa7dba8de8db364246d9e8390108a789de3f6a9628bb75c8a/fastar-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:7116a770a4e47262734fafe06d3e835a23b833e81b45b3054558301385a0f2a7", size = 698042, upload-time = "2026-08-20T09:09:55.257Z" }, + { url = "https://files.pythonhosted.org/packages/be/3f/c0ef2beffeeb01f9ec27b0ba8ce23697919b2247316d923f5a3bed6fc26b/fastar-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebe324ffcb3e8efb2308255f60de911bfa4ccf10f087b6e60c62606f091f1807", size = 626452, upload-time = "2026-08-20T09:09:37.853Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/87c1887bcbe311a913a2b5b2f4481cce9bae7dd05115575c9f6cbbc892f5/fastar-0.12.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4af6be78f7ec6ef8e6da7d162361e4198be10fe81bfc95112f635c2c14e12922", size = 856780, upload-time = "2026-08-20T09:09:03.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/d3/d4299d3c73df485d2bf095cca7e3829036f6a3fce26728ccfe4c3db3798b/fastar-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad38b27e93ba9c7de076ad694d224153bf8d66695acc27a114cba8087078fd54", size = 755380, upload-time = "2026-08-20T09:07:32.822Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ff/768ec3c6898fb20710ab222b8bc7caedcb9765a4c78122bb0267373f8255/fastar-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:968d64c203d10d257c2f2268cfc97d94f273fdfc64b37a4739fdb6e2cf2c3f03", size = 748975, upload-time = "2026-08-20T09:07:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/36/7c/0ea040fdd20fe90c39e008f5c8b54b54058afb78faf261484c67b4ea5e94/fastar-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21256ccb3946730e3d601b6a9c9de61a127855957a7f0852cb14b0dbe15f8aeb", size = 917534, upload-time = "2026-08-20T09:08:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/5a46751dff921b344ce995ee364801a867d80331406a96215336140182ed/fastar-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d89116b102fb7d4c47b6b723dc32d5c12122e7ffeb41b7a8b22cc777c9eca5", size = 808943, upload-time = "2026-08-20T09:08:44.785Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/6336def57f2b4701f93289a999a42de7743dfb78320a6b8931e9c0472da4/fastar-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1450e0325897e87594c29837fdd9e338b1d281f6c81d14d7a75b6776285b5b6e", size = 809624, upload-time = "2026-08-20T09:09:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/6e/75/bd2540d70c6e4deeae3693cd41725dc42d4a96ced0394a9f845ac6afe148/fastar-0.12.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ca63fee43f07408efec09e1c0ae34a1b29ae52b8c1adc31bd6434ccc9e1741cb", size = 874787, upload-time = "2026-08-20T09:08:27.198Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5c/13d20ec4d2c1e5ee15bf3b01fa43b282ff93f481d162e5ab507d8a341fbf/fastar-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fa5fd057b4f4537f04dd4e4f13be92433bf47bc479309335755531c5af34560", size = 966646, upload-time = "2026-08-20T09:10:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/fcecbb90c6784ca0d5a54a9c3f7edf78ae5393c71b8dd0088130c3a4c82a/fastar-0.12.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4b395c3d4375809d0d55b5ae297f6bc037b90b382f35f056e453732e4f6f523c", size = 1025855, upload-time = "2026-08-20T09:10:33.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8f/e865bf29f54c93a6fee55c7248ae64de91e0c68c6ce7f9a6d8c4adff471d/fastar-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:20d5e52c45e75a55ed27e7952487506269e9a64559d6cbfaf6977529db81298f", size = 1063126, upload-time = "2026-08-20T09:10:53.804Z" }, + { url = "https://files.pythonhosted.org/packages/42/19/4010752bcd7f7f290476fd4eec5f9611324b78a00575ad7224b4375fa124/fastar-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4497098ebdf3c1f89dbeafe44ebcd7f143f8a774641bbfa13d1d793104464c41", size = 1019854, upload-time = "2026-08-20T09:11:13.219Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/9dc92104a021396d2cef2d7d3a8a8db2f3409bc19f41a7e8370642e980f8/fastar-0.12.0-cp314-cp314-win32.whl", hash = "sha256:56cb3b3c46edf26f054f2420573c7c69c03997203a7a867ca835bc305b4a0f30", size = 451451, upload-time = "2026-08-20T09:12:02.371Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/b2f55b50aa446958225360a20a16a87a4717c2deeda4ebc85edc62b58f64/fastar-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:81534df96e775ccaa37fcd1f45e06f48c245ff77e30d3bcb0d3c1101da9399e8", size = 483614, upload-time = "2026-08-20T09:11:45.439Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0b/eb965694e157e09aa92857db73dd46d7cb10c24dae261a50dc46e0d361e2/fastar-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:fce60bd91fd982bf52e9a4c87820a44f92ac0d896bd64544891d6995fa6b8b98", size = 459422, upload-time = "2026-08-20T09:11:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/31/21/58b9e84b20c50d8cbd31d25aa99ce852a463e60c8f88b3a44bfcdecb46da/fastar-0.12.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:22c882f1096199d5e63f3ea4d9313e10799fcbc70166d315aa07576148601326", size = 696156, upload-time = "2026-08-20T09:09:56.953Z" }, + { url = "https://files.pythonhosted.org/packages/05/84/227ad56548f2de419a5fb948cedc866ae0aec6fa0cf057b50e96e50aebb8/fastar-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:74c928183d7ca19056bc0eb24d8c1907c115cfb2382ad4a7c32ccbfb04ca0a0b", size = 624449, upload-time = "2026-08-20T09:09:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/c7/51/8b05253149a568bc62b9a2d774f26a4b963dd91dae3da4e843a2cb7c5b40/fastar-0.12.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f1f3e70447f45e54b488eea8bd401cd3774b7b688d5b507915ae415058d8ac86", size = 855891, upload-time = "2026-08-20T09:09:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/02/77/e62ef58301d2d79d624fc17fdaaee61c1a827d5f4000a8482be6a20bd10c/fastar-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96ae27bbb807e39e05097bdc2a6cba6820f31dd053aaf8cf0a045e5969041778", size = 754529, upload-time = "2026-08-20T09:07:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/af/87/c35f3c3effae445d1b51b56ad6629df6d2001aeb5051646d081904be2081/fastar-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0d60682ed24fc6063b18e76a947f1cc0fcd0777709357b801b3e3458a87c2e5", size = 747923, upload-time = "2026-08-20T09:07:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/3e/85/e9556fbaa8183db72c6d3cffdb56384299b2bd4d7a5e0eb990401ea221a0/fastar-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:644f6d06e312bc47dd315aa36fba723854859601e184113de7b887613ed9a5ad", size = 917040, upload-time = "2026-08-20T09:08:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/53/8f/9a53202c1dcb3a5cd5661c04c201bff358919f23f6506e73533411cd96d4/fastar-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6c5addb01f206bd75f784540a8a11bbddc451293318a823e3fecaf0d62cc3c", size = 808402, upload-time = "2026-08-20T09:08:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/6a083aa6f7089f5ee7cfa99f15f299658953074035a6b7ed4dd52e21b8fe/fastar-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc892486eb242ac55dde185d547d2723289da50c6cdc06614868416576ba5c4f", size = 809412, upload-time = "2026-08-20T09:09:22.5Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3e/1c45d7da2e67134f161c25f733ba231df67fdcdd018ceb9aecc50feb01d6/fastar-0.12.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a47a68d1b9bd59062af41d9809a340739ab1ba13cb5b4beb23466a621d6479f2", size = 874673, upload-time = "2026-08-20T09:08:28.874Z" }, + { url = "https://files.pythonhosted.org/packages/22/7f/c56ddd4e7c9035170b4513c2cd24673a4ce7a2a6b6cb9a18970672e14455/fastar-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7d9fd762e7eab2262ec004aa9c714b53d303e11cca7c814b7b634d9d2424691", size = 965952, upload-time = "2026-08-20T09:10:15.447Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/329cca10ae74a8b40791dd59868276e66d9e9321d1c1c4f1284e6c864f9d/fastar-0.12.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3766bf0aeeb6a03d114b185472593a2a3f0ff43f1b56c40fe0cda4283f9f4351", size = 1024392, upload-time = "2026-08-20T09:10:35.46Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/3c6bda956cd88d22ed9cb3fdc15e43a6fccf58ac64b8856f5aebd23117c8/fastar-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:860516a52a3bdcaa746be42fc1bdbbbd48e05c7579f9f61e30d78a7e065835bd", size = 1062506, upload-time = "2026-08-20T09:10:55.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/bfb903579672ae213f5c81a5d289e3b9ca92ff2b3f789b08577de5f51050/fastar-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb75898c166ff6d232bceb68a5a7dc4a8f17239fb40925d169649a3967b76c4a", size = 1019468, upload-time = "2026-08-20T09:11:14.932Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/c0f6a5b18981425879938fdf8ae38dfe7ef031d7164cb5b40eec29e5a4a3/fastar-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:c5755332572756061b29766a9ceff2c837d52d96828e58f908cb46cc49123bd7", size = 449931, upload-time = "2026-08-20T09:12:08.865Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8f/832c7ca0d28642d5869a1aa194e4fb644bae2013101a1e9357168e6e1383/fastar-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:07d861c7ddf31bccb9615a0ba4c9f06987d1373a6b357183526d68cf9c3f5552", size = 482560, upload-time = "2026-08-20T09:11:52.086Z" }, + { url = "https://files.pythonhosted.org/packages/ad/10/17b9b24e129dcf3f4b4eb57b148888b128930180e9babd0e166cc828b2b6/fastar-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1d56e2a52bebd3e379d0cacc2b018b819a0b99f0dc19b4453f304c4e2fce5b3", size = 458209, upload-time = "2026-08-20T09:11:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/21ec24e28f98554f727ba78a8703009d44ddc8946a91715743e574ed3f01/fastar-0.12.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:6109ec55528a975ab3644dc1c9ccccb2c2315daa66ca34f54e1e3dca60afa757", size = 698704, upload-time = "2026-08-20T09:09:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/9e570204757c36d3ef1c94012294b1921a4c51af55da4684f206d88c09df/fastar-0.12.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6e94e2881c3aa53da5d9161e2e64c0d698c66506a62024d5900943098220ebc7", size = 626785, upload-time = "2026-08-20T09:09:40.933Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/dfaf600497cdd4ddf3942c188d27171689f370d6ea2156b7646c606be8a0/fastar-0.12.0-cp315-cp315-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:682c531ac174d63919374eaab6fca91f92432cbf6a2262acb72719ba2e2a694d", size = 857172, upload-time = "2026-08-20T09:09:06.417Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/551b13387ae7ce88f165e7201757f031309a0cc9226663245096fba40554/fastar-0.12.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf3d5bfd73516b506a916f6809b90a4ad73ff5840c0af6cbe0142417a03b014", size = 755384, upload-time = "2026-08-20T09:07:36.044Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/b8f2f3572e0ec95ca9f0eaf6889e6d7da65782461b5a983379f833d6a453/fastar-0.12.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fbeb5fce858248d6b9b1fbef12c3e14d174432728c6e6eb1e2a63447432571c2", size = 750165, upload-time = "2026-08-20T09:07:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/34/66/71d24540a462eb5dbff8993dcf674844ae8355b8f5ea63195c10207c10a0/fastar-0.12.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45164f7138613b76918101ea28842cb69c10ff441c1ba2d56d7c6b28053f28e2", size = 916891, upload-time = "2026-08-20T09:08:12.202Z" }, + { url = "https://files.pythonhosted.org/packages/84/b6/fc72480b7771ee14541b04ece4f94327a6522883ca51ebf46d9112723486/fastar-0.12.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e71715c64695bc80fd8fed2a82af30acb49b6bf085c06876cbfc2116b53cc7c", size = 808828, upload-time = "2026-08-20T09:08:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/88/1e/0cb98e45845e442cb04b4dcc5d3a149184b188ec41425960fec62ee140b9/fastar-0.12.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8056fb0f3ff2213eb00234d32b2d701cd288b763d3430b67033a393b8d33b47", size = 809399, upload-time = "2026-08-20T09:09:23.995Z" }, + { url = "https://files.pythonhosted.org/packages/9b/00/e568dfd06fbb14a70f165a9972672dc21418edbf25590229e8fb2176cd19/fastar-0.12.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a843704912dc3b20e152743bd5fa3e225bf9cc23c34fea0debeceefead477e78", size = 874588, upload-time = "2026-08-20T09:08:30.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8a/83ebe531a4a6fc93511c719ce8e505a2b23711e9ac4bed627cd7950eeb60/fastar-0.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b4a7b6ca4a04e269aa26533ca8bfd0c674e4ee7328b0d3d80d45ab979a7e613c", size = 966301, upload-time = "2026-08-20T09:10:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/8c37e19cc9248b35d1b13cb6d27828755e2b23be9dd06437b77b572c0a01/fastar-0.12.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:883f1e06c0d9649a2e54b767b3384b47306098ea73be3ca288d562c4d73dbcaa", size = 1026601, upload-time = "2026-08-20T09:10:37.159Z" }, + { url = "https://files.pythonhosted.org/packages/de/ac/4fb738d3ab7ede5545ebf8205beae5c9e3fb97088b68b2c2103357349efd/fastar-0.12.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:74858c4648bdc4450a66f3d6ec16a4e61ba48c16c9898a88a061d3272f82c65b", size = 1063319, upload-time = "2026-08-20T09:10:57.318Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c31c84446226f20a217bad3921528bd8214b714ab1aa634afb183c1d852d/fastar-0.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b16451d5b50579e4eb7dc1761946bc6f6186df44fa84310a06f455c26eb4442e", size = 1019807, upload-time = "2026-08-20T09:11:16.731Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/a44dee8cbc14601a91fc1818ea3e172cd5db2cd9af55f29c866bcee4c22d/fastar-0.12.0-cp315-cp315-win32.whl", hash = "sha256:ad8185a7b379e5cd81ef65209d21db4c63e8a62bcaa2d17fe40a8e50fcb28427", size = 451911, upload-time = "2026-08-20T09:12:04.003Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e2/1feebd0c4e7ea7839f250a4ab77663d3a6149124a5ff6ee01859d788b6a3/fastar-0.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:a2819b9061cee89da560156b77230d4ffe81e75b8f64b9732ca34d7bc546e49e", size = 483441, upload-time = "2026-08-20T09:11:47.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/32/a2eaddc9b4f63d1560d4467df586d427da52717e01e88d9a0202439c07c9/fastar-0.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:a8a8130f236a5dc2ceab88486f77bbdd516d08dc949d0f04194305845cf44c19", size = 459131, upload-time = "2026-08-20T09:11:33.448Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/cffe7bae35e9e80789396f03cae4e8c4c761c3bee6003d2e622f6c9d8d6b/fastar-0.12.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:6f25c1aa6d55a457d95dc2163bbc27942e1541ca6792d4bf323a922688b8597e", size = 696600, upload-time = "2026-08-20T09:10:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/ab/75/5e28ef81c3fac04a8d04a1068399069e28e4dbc6df221254c3a75b43af4d/fastar-0.12.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:aeb69fe64537deec4902f45ad9634b85d44ebb42ee1a33725d6584e8d9b33927", size = 625371, upload-time = "2026-08-20T09:09:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/234c34a70d5e9e30420f43a9bad7a0046593ce5d65c2452f147f0a65e86a/fastar-0.12.0-cp315-cp315t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a83ae278bcc718dd155219fbbd552a16bd8c178effc5021600c3be2806a01cf", size = 856582, upload-time = "2026-08-20T09:09:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/bd327da80d86309f6f5806cb3af70f91473d26fafe53f7cb646aaf35a4e5/fastar-0.12.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048c732e3ca28a132732f83130ccdab58d9b27dd36bb26bdeb42c2d48827da9", size = 754890, upload-time = "2026-08-20T09:07:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/a72bf87d4a26ef1bc94fde3b4c37c97500c1db5e2431bbfba865fabd8eb2/fastar-0.12.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:613466f628667af03de8f914de58a07bfb3ee1bd0347e3532ec9790df92a1e72", size = 748828, upload-time = "2026-08-20T09:07:55.27Z" }, + { url = "https://files.pythonhosted.org/packages/fb/63/2880890777d680271115ff6b2e8b891069610463308e7779e0fa9c8d9e75/fastar-0.12.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f59d3243d8913db385ab822be8f111f188218ea73f9f14f5d70c869a33ff4d1", size = 917262, upload-time = "2026-08-20T09:08:14.243Z" }, + { url = "https://files.pythonhosted.org/packages/08/06/b3293a2a8bf7bac81e848fcc1c67411a0770b186a3bfe5232ea6dac5e929/fastar-0.12.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b362e5404dab262e85f0d93bd950933a0935dac6a9f5f0516bba6c703c440ec", size = 808819, upload-time = "2026-08-20T09:08:49.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/0c0e9c1bc422272df07414d137d25eeb4bec8b3a8966c8e2b179967390fe/fastar-0.12.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d1e50c423cd064f29f11c98f6d995b8fa7df7bbc19f3fdb9f081859afd8e00b", size = 809620, upload-time = "2026-08-20T09:09:25.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/34/f10ca8db20176ee9e685da80fcdad2c79e66485ed025ee16881e8113d97f/fastar-0.12.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:6857a79691c5c033a31d76c62ad02f2c92d173a0e1fb2fac7fcb7ac686108bd3", size = 874739, upload-time = "2026-08-20T09:08:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a8/547881f35496d5b5b64f3e29552475d92d55d7bc213244bd7fb59938a116/fastar-0.12.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3053bb800b5375fbca8f96d256654ae3489c439f1b55766896d1c703d8281804", size = 965821, upload-time = "2026-08-20T09:10:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/a4376b6e70e6ca8909b8788b6e0ccc55cb9f0ada4c2bf7f95490d06659fe/fastar-0.12.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:067094312cbea82ef2efa3999dc31318ac539805965c9505d99654f01775cac1", size = 1025655, upload-time = "2026-08-20T09:10:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ab/48e90600f5c08e7869c8cbd346cb9cf846414f85ca1f9e7aa6976222c6b5/fastar-0.12.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f69400ebb83a8d754aa7735165c12f8029ac577c3c08eb6d174eedc5901b7cb2", size = 1063028, upload-time = "2026-08-20T09:10:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/e113f8aebfd874d3c78945a094fc803ca295b92e12dc0d80ec81e2a7701f/fastar-0.12.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7d875d99946a11538f7ecb183f0a885d1d0a0495a6f1d6d2aa1de9b5fe6e5e9d", size = 1019546, upload-time = "2026-08-20T09:11:18.523Z" }, + { url = "https://files.pythonhosted.org/packages/51/45/72c2bf5ae3386407009fe51c40e548e41f70181bbe34a6477bb6078be047/fastar-0.12.0-cp315-cp315t-win32.whl", hash = "sha256:39dad3351f1399cd28e2e649b6651299ef857df6c745e598b00b4167dcf93dbc", size = 449932, upload-time = "2026-08-20T09:12:10.589Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/0adeb47b838c62ebd78bdf11ee2255e6e205ea176f9147978efa022ef855/fastar-0.12.0-cp315-cp315t-win_amd64.whl", hash = "sha256:00cda9a3f11871261a4e77a3b8f0eede85c9730fb7516811bcbcf96a2bb3b75b", size = 482858, upload-time = "2026-08-20T09:11:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b2ce5a79c57150d36aa8e0514dd65999ea76f091242aba93c403cd20e491/fastar-0.12.0-cp315-cp315t-win_arm64.whl", hash = "sha256:e8e0fb057b5c271f46f3300b539b0d3dab8c8cb2515205a37c818c2f68d16806", size = 458287, upload-time = "2026-08-20T09:11:36.952Z" }, + { url = "https://files.pythonhosted.org/packages/99/62/ef58b40eaec831be0a92c0fc56536e454dd474176528f79590062cbe27c3/fastar-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fa463374dde07fbb7a46fddc75288554ce9b89f5d0129fa073028e2e13d6db3", size = 699284, upload-time = "2026-08-20T09:10:05.361Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/f4247f505dece38e7e45850b0b5f62139f85889794e26c0b0d257070ddcb/fastar-0.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4da24effffd801bfa5d39c7b68c59a1124df8ce8ddb4048e1a5bbff4a915248b", size = 631951, upload-time = "2026-08-20T09:09:47.235Z" }, + { url = "https://files.pythonhosted.org/packages/93/8e/9e05cca947c0ec6bb68ae58c4ed3496ad475f65724822ca21bd44076da6a/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d3f780bd6d4e39df5b21862d23df636ad03fea45d73e105bba2493f467fe2b86", size = 861565, upload-time = "2026-08-20T09:09:12.831Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/75f5930e5516eae44e322b5a8c19101457f7d7192586dfff7f3160336ca9/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172ab2b39a9307b0fc56e603b57d01d98f92bcd19cc78c1d3451142d00b3eee3", size = 760630, upload-time = "2026-08-20T09:07:42.441Z" }, + { url = "https://files.pythonhosted.org/packages/cb/37/3e8c6417ee220fa723cd459446705f067464b0e63eab98fed169ca6b636e/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d47478fc6ac75e6a2f7a0adf7d5d0636f36fbd2b1269bf87530c5bc404af1c1", size = 753275, upload-time = "2026-08-20T09:08:00.492Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/2f8604ff20ce259b4ee82590a44774344525f67639716e9e94170ac82c4f/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a84c2c04e77559363e7da7b3a99f94a48d938043de09bfc13042f4437ae1f86f", size = 922917, upload-time = "2026-08-20T09:08:19.385Z" }, + { url = "https://files.pythonhosted.org/packages/19/45/ab80bbfdcee67dc588f10a7a3ca93f6460fa95b9b71d51cebd86a9d0155d/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b57afcac03ef59dc3b26b0669b8da7f61d0d8961896b5a809c3d6b31f664ec0", size = 814103, upload-time = "2026-08-20T09:08:54.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/861a52a25584e407c0d2e7d905d93d8b79dcbf6aaccb9a57a3a7fd2fc7eb/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb38929383fefe38909421eb0bf9d0ae058c541b93e80383b678755eede190", size = 814925, upload-time = "2026-08-20T09:09:30.031Z" }, + { url = "https://files.pythonhosted.org/packages/90/10/d43f674ec205ba22252e5c68afcabfae620ae27374889b6f9012762d6bb2/fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:7f00ba54a5b44eeb6075005915a8a11c93ff04cf82ac620c7d33105cf4f7ad3e", size = 879861, upload-time = "2026-08-20T09:08:36.78Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/62a464e871dc6d88ff313f23499388e2ae86bd38e0e648f9f188cb18e4a4/fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:7fd86318da844a9553e02d0d1dfdeb459b4d516c49c8ed66264ae59a40e3b7aa", size = 970950, upload-time = "2026-08-20T09:10:24.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/19/a7dedfd11d7284b06fcc12edf1061fb1d5dfd9fceb914c695ad09dc280db/fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:b9dbdd0d9a7082d924489411bf84d8dd392f18bbfb4379a60fee97275e6d8c1f", size = 1030316, upload-time = "2026-08-20T09:10:44.31Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b2/94e2a280eae42b9242331938f0013b6b02c02ad369eef205ba822bc98f21/fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:689fed84ab320e428fb7ee1ee7ad5dae856f8d75c12df6860258fa94497eae7e", size = 1070283, upload-time = "2026-08-20T09:11:04.32Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/62c8477e775e1c035b817fd64964de3f8bd5743a53ca547c656484f5c356/fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:70077a6d327ffa5d54f9d7bad6e6583c010530a5b120f5ad71a83b0a14d26ad9", size = 1025083, upload-time = "2026-08-20T09:11:23.724Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hexbytes" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/4f/eabe45c58f2d27cd0b338ecc41b0b475a3751ed70eb1a21db08497e3ceec/hexbytes-2.0.0.tar.gz", hash = "sha256:01312fcd5c57e8a8d2d7dd3274dcf84ea50422aff2abcc2d9fd89ad6a32498e5", size = 21344, upload-time = "2026-08-21T20:22:09.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/c7049aabd9e05efebc29b10208b11e5ae5cf819f48685ffced6abf871d10/hexbytes-2.0.0-py3-none-any.whl", hash = "sha256:5425bd7ac83cdd9791c13a5bf97cfe9b9609a304b1ef3ab146adfd50de06cf0e", size = 18866, upload-time = "2026-08-21T20:22:08.654Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.15' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.15' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonalias" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/45/ee7e17002cb7f3264f755ff6a1a72c55d1830e07808d643167d2a2277c4f/jsonalias-0.1.1.tar.gz", hash = "sha256:64f04d935397d579fc94509e1fcb6212f2d081235d9d6395bd10baedf760a769", size = 1095, upload-time = "2022-10-28T22:57:56.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "parsimonious" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/91/abdc50c4ef06fdf8d047f60ee777ca9b2a7885e1a9cea81343fbecda52d7/parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c", size = 52172, upload-time = "2022-09-03T17:01:17.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyunormalize" +version = "17.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ab/b912c484cfb96ba4834efe050bbf10c9e157bd8189eb859aefba8712b136/pyunormalize-17.0.0.tar.gz", hash = "sha256:0949a3e56817e287febcaf1b0cc4b5adf0bb107628d379335938040947eec792", size = 53121, upload-time = "2025-09-28T20:53:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/61512483dc509e3ae8a42fb143479d1e406ce1d91f8f08d538a3dde39c6d/pyunormalize-17.0.0-py3-none-any.whl", hash = "sha256:f0d93b076f938db2b26d319d04f2b58505d1cd7a80b5b72badbe7d1aa4d2a31c", size = 51358, upload-time = "2025-09-28T20:53:04.876Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/13/bbf7d9d1887fe4a3693527c6caa232c197ea9da91f1212e9672eff60329d/regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b", size = 494009, upload-time = "2026-07-19T00:16:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/a3/19/783688e75a2bec15d50aec0d5e7e317d363808bc82a6eb6750b897bfcd7b/regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52", size = 295287, upload-time = "2026-07-19T00:16:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/cefe4f051302ca298d3f3e79ed6dbd933ac84485b9515acdd6a52d70cef7/regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665", size = 290633, upload-time = "2026-07-19T00:16:17.182Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/1045ca2cabb12e8ec41ad0d138e9f3ff1eb079d30a6f51ccf7d709b44aad/regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0", size = 785300, upload-time = "2026-07-19T00:16:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/20e5bca184e90bf1bd187efdb53363f4a7b7b34f01d54ced5740caf104bd/regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951", size = 854079, upload-time = "2026-07-19T00:16:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/09/9b/5a2e59678be3b24aa6a42b2c6d66a48daa212593e9f4096fe7ba577fa9b1/regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3", size = 899496, upload-time = "2026-07-19T00:16:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/48/9a/7317f14ed8ed9fd998d1978b4802b07bc4d79216353c435dbcc1ddd1301f/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c", size = 793541, upload-time = "2026-07-19T00:16:22.991Z" }, + { url = "https://files.pythonhosted.org/packages/6f/53/833c2db3e274d3c191f4c42fe5bfa358e4c8b617d5d7312d31334965fc46/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6", size = 785515, upload-time = "2026-07-19T00:16:24.654Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/efb2f9fa151d71db09d4015e1fb92fee47416f01c12164836bbd23e2f3c2/regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175", size = 769556, upload-time = "2026-07-19T00:16:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/45610c263f8eadb84e4a1fabd904d81d5176226faa9104ef498bf8a8b285/regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6", size = 774130, upload-time = "2026-07-19T00:16:27.786Z" }, + { url = "https://files.pythonhosted.org/packages/40/95/1b40d87c7a9e5480bec7a87bce9fd67fc3f14b5f106c8ee66d660249072f/regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095", size = 848694, upload-time = "2026-07-19T00:16:29.412Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/bb45968addd5b394ef9cd9184bd9c65ade1a819dbb2b92b71ad52a0c7907/regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0", size = 758505, upload-time = "2026-07-19T00:16:31.006Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/33386c672fbf43e21602135a0f29a97ee251a483f007fe51d10e9b2dbc93/regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a", size = 836985, upload-time = "2026-07-19T00:16:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/22/f1/9112b86e9bb075619862e8e42b604794389f1958faa68fb69bde505dd90e/regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902", size = 782610, upload-time = "2026-07-19T00:16:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f9/13d460d8a385ca0b0be9e6be80a90968b9293b3e30895543ad2d1d1653e4/regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e", size = 266772, upload-time = "2026-07-19T00:16:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/9f/90/29addd7a03e1aea402c1f31467e25c80caabc8c3735b88a23cf73b0aa9c2/regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db", size = 277967, upload-time = "2026-07-19T00:16:36.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ba/ecfce06fe66c122bc6f77ae284887a9282e4411fe1e6268c5266611ca054/regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6", size = 276963, upload-time = "2026-07-19T00:16:38.315Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/3a/a258c2fbc6c6bdf428611388f5698ba5d57ffdf0755e1cab474d9cc47813/rich_toolkit-0.20.3.tar.gz", hash = "sha256:223dd2cfba325ed55e94933b9e53f3aca13e9fdf76622bd564c18109a2273c1b", size = 205355, upload-time = "2026-07-13T14:38:06.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ce/639d0d0ce3d25c5edbd1afecd308bb35dc04883a45ac9f0855c8aee4e919/rich_toolkit-0.20.3-py3-none-any.whl", hash = "sha256:419aa87516d5f3849cca553c6dcf707c02a36d508fcf996946606725d34a3002", size = 36195, upload-time = "2026-07-13T14:38:05.687Z" }, +] + +[[package]] +name = "rignore" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/7e/aa0640d74f6b4bb68466f5899bd5ed1680480732344c31a408504e215801/rignore-0.8.1.tar.gz", hash = "sha256:2b6cf58501e9ff1b6a71c3fd66c8a105311e1f23237626fd4c9c00606bb3d30f", size = 55535, upload-time = "2026-08-04T22:27:08.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/25/0591c13a21203b778fe9a3ea70c0b355a8b395fb583105ae428564cf5f47/rignore-0.8.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b56052bff3aa1e6dacb42192be701efdbcc546c52f6302d4146ed93a31df4593", size = 848641, upload-time = "2026-08-04T22:22:48.897Z" }, + { url = "https://files.pythonhosted.org/packages/96/36/40ba00996544ff5c581dc1a533124c070a28802fb864ebc53644da3607e8/rignore-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8625d98b9d83ef55f11c06155bf31cee21bab9c1fc001459a7cca878bf1e8c1", size = 824614, upload-time = "2026-08-04T22:22:50.638Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/3cd57df315be06d138fcb6add5b3b69c5fa6e0944cea9cbc74849b19e94f/rignore-0.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8a6b6664b3e9b403af1abcf81475782120b52e1f4063250638dce18a1856fb8", size = 888364, upload-time = "2026-08-04T22:22:52.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/5f8e33ca999befc746de9941bcbf4b59599d580f9e8cc290b7f2ef0ebf53/rignore-0.8.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2584771ef0e3e6c12f19bc50eafc1aff338e125efe0030ba0c3b29731faabf6d", size = 861461, upload-time = "2026-08-04T22:22:53.496Z" }, + { url = "https://files.pythonhosted.org/packages/78/70/fcaab9ab94139015fe489ad127e541901bd885ac9d16e9c7ec691079e1e8/rignore-0.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:773acea1c43241d5e96db86d52133f3098246337639a1f6f474ee8d5b304b36b", size = 1140078, upload-time = "2026-08-04T22:22:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/104995d5ea5e1f94fa1e3971645da36c55e99dd5cf04e23f6ffd91a5f730/rignore-0.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a742a2c09a63bd65a52ed6047ba324593b6bf2080bfc570af199b00d617a7349", size = 918639, upload-time = "2026-08-04T22:22:56.208Z" }, + { url = "https://files.pythonhosted.org/packages/83/ad/911b69cbdcd4fd2a53822bafe6108569386ea67a4df2ca75192b0e34a695/rignore-0.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c76ef4db664b9bde5da57f1cc58ed8545f9dfee864fd51eca21c60ebe9206dae", size = 933947, upload-time = "2026-08-04T22:22:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/b0/40/1429f894894d4973180e7ef20c4c03a7d76f8ffd341e05942fd7ea319a99/rignore-0.8.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:39478de64db6d233d1bdfca58fa8223a073a13f0facf992ba05a739a093fe4ae", size = 898966, upload-time = "2026-08-04T22:22:58.917Z" }, + { url = "https://files.pythonhosted.org/packages/ce/50/ecbfca319466447ea3ca411c29c66797c369514786499c265874a0c76842/rignore-0.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2f0ea489c0e3439f1e94f2323e55218321b0a5f098dda4f1537e1caee12b035", size = 970464, upload-time = "2026-08-04T22:23:00.136Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/cdc7f9d8059dc0c18fe95773ae2ab7154b6ff69d7f2e0d67bd3e9907913a/rignore-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7d38b47249754453e435d728ec019decee5ab450d50347025605484aa7526bdf", size = 1065538, upload-time = "2026-08-04T22:23:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/49/d9b6e6498f6ce4d8991c7794cc8fa573c264dd9de24ab3bc743002ea8d1a/rignore-0.8.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1524ca0df28b6c967fb9594a00cf1ba79e280dc513ec3e02d29345c79390e028", size = 1136448, upload-time = "2026-08-04T22:23:02.907Z" }, + { url = "https://files.pythonhosted.org/packages/73/68/1462e047236798ed08ca6ae6971abea02419fc60cb1347c4ed453f85db8e/rignore-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e0ac8e46f89a824326626f4023253cdb8dd09c55c65da51b9824200d261697a6", size = 1146368, upload-time = "2026-08-04T22:23:04.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/8c/049c5c04b9ef2771bebbe59c93682561b0dec4309579a69a9a95dc431ed4/rignore-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20743f8ef88aa6211f5ce3618c2dda34dd0afb4553e70ed7b18ef4cc1a0d8d9e", size = 1143470, upload-time = "2026-08-04T22:23:05.663Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/729521cec832ebbf2ec8e6a6fbb35ead0e4321bda0970aa84774966e54b3/rignore-0.8.1-cp310-cp310-win32.whl", hash = "sha256:b2cf5a1498d7f1a14fb5599e59805875cefbcd8b7d989e3b73f52829200ffc60", size = 642662, upload-time = "2026-08-04T22:23:06.964Z" }, + { url = "https://files.pythonhosted.org/packages/52/f4/d5a3e7fd2b4bf0de971ef350927b7dd754ec4e5b5a77b60a5313b91c0f0e/rignore-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:54bd28cc90dc4c89c92a9191bc899e84786087bfb7c417ad0c29aeb90c2d0333", size = 731016, upload-time = "2026-08-04T22:23:08.262Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/0867d6f744a4a99a93513a6df42f7ff92d008539c850ecca1f9e8d89814d/rignore-0.8.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e4bc5ad282f7f82d9dcbb51b2e079f9a09c189e8c36017833e34003d253e0117", size = 849298, upload-time = "2026-08-04T22:23:09.567Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/1242164cca973f02b7a3545b36e736f2d3d04a424e04a88e58d81bf4414b/rignore-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36381a30591b410b15d342279e1b3a3515e240a734c37d119cd0a9e6f71ef596", size = 824679, upload-time = "2026-08-04T22:23:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/ddb14543c0fde4a950d2924a19959ffbcfa6b3088c862f9d9a13e76acb54/rignore-0.8.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2aa76742832db1fdac070fa8e694dda0daf6f2f7191832bd555d1e80f8c9c42b", size = 887919, upload-time = "2026-08-04T22:23:12.353Z" }, + { url = "https://files.pythonhosted.org/packages/d2/27/2fdd67883dec9b9a58c433c5af1d7776064786f1b0078bb07799682ce310/rignore-0.8.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6c53ebb7ea2d22116d99ec155c5313c3d76220f06b4118c1e1de629de721a95", size = 861417, upload-time = "2026-08-04T22:23:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/bd9df565ae9d5a751f10c05e660d4a0640414385e440f80c5d2c6c85ca1c/rignore-0.8.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0dbea2e28538fa64f8409085abf50240f1f7c5981a5479bbb1aea7b84d44aa45", size = 1139170, upload-time = "2026-08-04T22:23:15.054Z" }, + { url = "https://files.pythonhosted.org/packages/86/d9/dfa38aca07aaa401c1cb07b5810d68dd84a4fc81e44a0f1da2daa97dcdd1/rignore-0.8.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05fe665768bfe8ddda77e5ef1c2332555ea2abecd33aad05fa41a19d959ba011", size = 917578, upload-time = "2026-08-04T22:23:16.422Z" }, + { url = "https://files.pythonhosted.org/packages/08/dc/dd73f3d47e3da48ff2535d24ee2dfda3ecacba9ad5f96b96a15860796041/rignore-0.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9869b618dc28104003292f26cad26a2d54f6a3941684f16fe9ff435b5df1b0c0", size = 932629, upload-time = "2026-08-04T22:23:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/33/ce/d5fa33660a3dc494d6cda4dc012b2bd738e80825340ca313b8e37de72fd5/rignore-0.8.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d59e4ee62d89136eb3f0f04db023c17f9294cde905cae2a0fc8d89a4ab86c57b", size = 898518, upload-time = "2026-08-04T22:23:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/d4f6aedfb0a0bbe29ddbf1ed8046de2fadb02d8db70334fa8a45b9a8c396/rignore-0.8.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df64fa4f0b451cf198bcb1596bdce1d976396e579188141276d4ee8288ee2486", size = 970620, upload-time = "2026-08-04T22:23:20.572Z" }, + { url = "https://files.pythonhosted.org/packages/52/0d/c1b147143cbac03da2c6252482f9b62e95bb2935daf2949aeaf8766bb7bb/rignore-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1fd861fe4c1fea39541e7c9e8a409472b9cf7604bb472fa9dc09c97bf7134b0", size = 1064904, upload-time = "2026-08-04T22:23:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/8e491db960cc9cb553ab75044faf7d0dc4f178ad5a9cea8c44c1b2dcbabf/rignore-0.8.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c49ea7bac11f3bed7af4a21fb76dd7820341103dd3d68676a7177d797262b84c", size = 1136229, upload-time = "2026-08-04T22:23:23.763Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b5/05e729987b85a4cd7422f06159554c50060c1338bebc9a19b26f8e34ac8c/rignore-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514f7be6d04a6f6be4835748b14bdb027799759d02ada03ead064a50c763b70d", size = 1146385, upload-time = "2026-08-04T22:23:25.25Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/d7aec128c2c26fe029e3d05e919d1dbe9fbcc108f46e1a9e71f5955ddda4/rignore-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b493c787dd2cc31b1e1c678e626bde593d83f9df420096f91dee6841ca317bfd", size = 1142766, upload-time = "2026-08-04T22:23:26.565Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/821b8f3111a083ce9d90a6c3ff87a7b5778ba0e65e37eab1343f896e6f63/rignore-0.8.1-cp311-cp311-win32.whl", hash = "sha256:d398c9e0d2a51ad16c296ea287a9e70d75715ff5dea124a7215228228cec8f22", size = 642850, upload-time = "2026-08-04T22:23:27.9Z" }, + { url = "https://files.pythonhosted.org/packages/02/22/a64401e774a7bb23ca584a36e8533526e94c2ae23341b5811760369c4673/rignore-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:516ff2dba78230ed5a150db4fdfc0ad435a5a9137d27366e20a311001ca5a878", size = 730892, upload-time = "2026-08-04T22:23:29.083Z" }, + { url = "https://files.pythonhosted.org/packages/07/4e/998c32618705fad2f17ac90a79b36c276b9ec5e7930bd672d88d01b452ad/rignore-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:13addb2a4805736a3c8adcd1c60871f517a4bd8b3a2b127e36d1e701924ccc0f", size = 669752, upload-time = "2026-08-04T22:23:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e6/b4230258f4c84be6b21f399c7ddf2c9675ee623d5aa54ae0881a618bed3b/rignore-0.8.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:487fb09f98c1e771f52b8433880c0012c25c5868db6e6f9f740ee0d3a13d4ae1", size = 848374, upload-time = "2026-08-04T22:23:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6c/1fe281b2e9f8876ee5cd1b02ae891312c6543d60764c2a937dc4a5f28285/rignore-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e7ba47a5bb25ad45d39983047ecfbcba01ecda0145458c548cd3f390b73bb7", size = 815871, upload-time = "2026-08-04T22:23:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/14/df/201b3ec49a4714fcad4515a6e1f2d55d3c9e3d8b43a5eb9307a8dfbff506/rignore-0.8.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac0a7cadcf6154dd60b2f101425644179b34c540a88b84089995c1745e8c623e", size = 884566, upload-time = "2026-08-04T22:23:34.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/c4a64587c650f8c28c3d8afec3ce6a628a8d0ea41ee8af58389e5882d3fd/rignore-0.8.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab53a0908a1f24d2aaf4201920932134fbeb19be1d4c9619514ef7c781c9f3cf", size = 857414, upload-time = "2026-08-04T22:23:35.737Z" }, + { url = "https://files.pythonhosted.org/packages/00/6c/b8345ef35e5cd672df57a529f44c7f01a38daf3fcbd1afb7046290254c3a/rignore-0.8.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd43540d294fbcb66daf66836b6c57043c0d3af672722099cd4a0c91448d948e", size = 1132587, upload-time = "2026-08-04T22:23:37.109Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/ac83dc97554f183d70a02c202e089ea91aaddabddc173b0cdf9f23ce022d/rignore-0.8.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b4316b266e88c25ac7b4a0c765aa93c436dfa689a29429bd276878db45e17153", size = 913989, upload-time = "2026-08-04T22:23:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/c6e6db62226df8cd3d950889925d826a0e35ac567cc5df548a872944f1ed/rignore-0.8.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e68cfc4ee0a2909952af2aebd608d1cd22f7d1cdce332cb0b5ea3762939865d", size = 927835, upload-time = "2026-08-04T22:23:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dc/850c839ba4ffef76bd4ead7c3556c5e38d16f4cd9d581d01cb97496d3690/rignore-0.8.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:4d891dbe52b1aa4df22a69346e731ee3df219128956ca4391722773e6baab16c", size = 890590, upload-time = "2026-08-04T22:23:41.358Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a2/a17d83b8923dad2d809c515b6dae89304e4057fc9422c6cc7af0a6ce4c3f/rignore-0.8.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:463f9734f06f2dd939c45f0e58d50c0648f799e9c41fb657e40d236054b14cfc", size = 963504, upload-time = "2026-08-04T22:23:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d8/a5147a1c60f73dedc61c3af1a1de36f11a4b2322d7482d4ef4306e6915a0/rignore-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97fceadfe03f3b8cd77cceeed92d00fad67f3ab80a0fd4d31e8dec104b721018", size = 1060732, upload-time = "2026-08-04T22:23:44.437Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/7f5e99e2b63725ae843daedfabe02d14c513778ede4dd4ca91a1771d5107/rignore-0.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:43342bf37e7bb57d69f766678c2b19fecf2b3ec757be3f1bb2fb774d2be8c81a", size = 1132151, upload-time = "2026-08-04T22:23:45.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0a/45348d4292464afaa5d659d3afa64580dd74cd06bb0de327159b1f9ed07f/rignore-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:514000eabd84e8f6f6a589a30d00e76d7cebe0d99b6cacede1223f64dca4b742", size = 1139846, upload-time = "2026-08-04T22:23:47.493Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/33e84dc397275787d11849b5be0175c297d6210a9d3058d1c1b56c945363/rignore-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8dfa13e24fc32d3df33d40788cd04e07f6b607f3c5306808ee0c285d2135effe", size = 1139121, upload-time = "2026-08-04T22:23:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/42/33/a1de70bc925d072250371abfdfbeeebda463aca9c049dedd78180f2ead08/rignore-0.8.1-cp312-cp312-win32.whl", hash = "sha256:39b300b82e90f7a6ad102544b777f2aa8a26e9260ebb021399ee5a0a708a7c2d", size = 639566, upload-time = "2026-08-04T22:23:50.2Z" }, + { url = "https://files.pythonhosted.org/packages/2b/65/db4fd0b4b8daff0f9810e2142ec2375122bd756a45cb4f163e1ca3aea905/rignore-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:562055b620844654f0afff4df0a9c83f3679452c276cfa76cc5e49c14ddba481", size = 728384, upload-time = "2026-08-04T22:23:51.733Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/bee47064ef6225ddb15333ec846b8e518b3e83d8654c8496734f8f3eb3c4/rignore-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:19606039e35dea7c1a8360f76fe39c5df6715d593a4f2e0c0c712b1b50985d10", size = 666363, upload-time = "2026-08-04T22:23:52.969Z" }, + { url = "https://files.pythonhosted.org/packages/82/b7/ecb7c0b91c8d68b313e3c41796860450b3f99d70ba2473d35f0cd3d1fc28/rignore-0.8.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:16b17b02d3e6ff3b88583af4809b64e14c77cf4de195763f063e5e77d70cd112", size = 847304, upload-time = "2026-08-04T22:23:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/c2/78/71063269a004ab6759682cdc2c8553d1a42bab56aa4bf7f4ffb1468913a7/rignore-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:58d7172259fd45d8ba00f3e02af5af2113f547f4a7fefab42cab567aa7c999fc", size = 815633, upload-time = "2026-08-04T22:23:55.584Z" }, + { url = "https://files.pythonhosted.org/packages/83/94/d87afacf13f5e32a844ffdd2d906a3c8d5ce24de0a91ff02a605322f34bc/rignore-0.8.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c5ce3b10ce4b716abc535bc0fdd66b0b9fa9f5b1987b36d3f5ece7d0e2a9a81", size = 884581, upload-time = "2026-08-04T22:23:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ca/ea796f9ad84f8b671d682e27bd1a60a7679b844faaf582b11f7e527967f1/rignore-0.8.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c199fa2d4a898e9b686d846371ead3c8e08d3e29f78e2ecabf4c580820a8f764", size = 856803, upload-time = "2026-08-04T22:23:58.456Z" }, + { url = "https://files.pythonhosted.org/packages/75/71/0f3e6d0c421c7a5f998a21d3314f76f26793bc3af851a5e43b1e82d290aa/rignore-0.8.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7136b7ff29c37c8ec8effa3c59e27839482ba542af94fdc2581a59405e99037", size = 1133897, upload-time = "2026-08-04T22:23:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/0861528542be468f493e809064842fbb595706c0adce02d3a6e56f15ad2e/rignore-0.8.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32b0fcd01495cc4f4d10b5307f9f15818c9db752cfbd7a9ecb00b0f129a70dc2", size = 914178, upload-time = "2026-08-04T22:24:01.316Z" }, + { url = "https://files.pythonhosted.org/packages/1d/19/769c0a832d0f0a8d368f09ef7dee4caf53d46f391de489532bf44e1eee1d/rignore-0.8.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ca91c91a53135889945e77b286215ecd41c8f1090398db97ca3459c5a290eb6", size = 928035, upload-time = "2026-08-04T22:24:02.735Z" }, + { url = "https://files.pythonhosted.org/packages/50/e9/76cf08722a4d93836f7d416877f1a0d8e9a456f2e145ab57f12b301ccf70/rignore-0.8.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:67e30c0883f9aef3bcc45e5dc6980bc41f161da6c1b1a460f0bd87064c1d6594", size = 893393, upload-time = "2026-08-04T22:24:04.306Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/ede8c973b1f979b81a4cc48530107538aa9ec7df97c98759415f958bb0eb/rignore-0.8.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3012fc79b19953f76a67b2e6bae5f456742e96f0ed33d3c6c9390b0586fc2da9", size = 964677, upload-time = "2026-08-04T22:24:05.965Z" }, + { url = "https://files.pythonhosted.org/packages/57/22/70af384dde865285d71f5deccacedc89232a7e04fed558d035dd31d001af/rignore-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3a437c870f1465aba36eb4ac7108c09d5097c436123fdaec980e7a26a4595141", size = 1060291, upload-time = "2026-08-04T22:24:07.354Z" }, + { url = "https://files.pythonhosted.org/packages/9e/93/1c661799fb7270c55122efa0ac6211fb49b066579095765db90faed06af5/rignore-0.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:59f4f92ec5165619b3bc58130ab558bc618f9c5e9df06807339a85d965143fd5", size = 1131079, upload-time = "2026-08-04T22:24:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/95655b6d2c6d2e82414f431b06fd7a3e6198adedfaf60de2b924c8f8d344/rignore-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:304ddf5f807c788c08a8210f9977131e7bfa9aa94704668d127689c00279ef41", size = 1140860, upload-time = "2026-08-04T22:24:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bd/efd83ece6d828f908c751422849ef219847e084fb749f08aeb294e3a48d8/rignore-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ce5c6d8f02badc55112b014d4fc9af662f0649869913d25785d7b3676ac19ed", size = 1139561, upload-time = "2026-08-04T22:24:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/49/ae/7609946e2f17f4d820f53a50e683702806a21d313939a6bb2cb5478aebd6/rignore-0.8.1-cp313-cp313-win32.whl", hash = "sha256:711369d017f8959e3a253dedb76f6d2f43eb669a4228b2c1b796e30a5370ca36", size = 637370, upload-time = "2026-08-04T22:24:13.165Z" }, + { url = "https://files.pythonhosted.org/packages/ca/46/b14c894ddc21feda8ca21810c7227de567441d90602f6e4620d623b958be/rignore-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:0585eafcacf15f8f2764b8d03a579761296e8c51dd0558f8eb59040810ba8404", size = 727769, upload-time = "2026-08-04T22:24:14.487Z" }, + { url = "https://files.pythonhosted.org/packages/99/49/cdcd08577d5ddfc31c32f5b14528eba9dcb361ae652eed7f2d0207e313c0/rignore-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8ddf078c4b5da0665a83f7b558dee2d746293ffcce3dfe8786015aed0aac4c2", size = 664478, upload-time = "2026-08-04T22:24:15.804Z" }, + { url = "https://files.pythonhosted.org/packages/3c/13/7ceb0fe7a5a5a67e7f1cee65d8a9b09c52df53a4ff406d14940130a420b7/rignore-0.8.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3054fab93e2d2ad483cb89417f7b883cee025cb22286eb824af0073f2ffd5f7e", size = 849303, upload-time = "2026-08-04T22:24:17.126Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/fe151f1a3d93483b36e22e8a0e84382cff3b7fe9d7347cfb586727054c56/rignore-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60f1ae9eaa51b50d5afe99bd25ffb2cfc10729701ca08c179489555d2614f3ae", size = 817293, upload-time = "2026-08-04T22:24:18.584Z" }, + { url = "https://files.pythonhosted.org/packages/d4/08/cb82a725f040cc5d106a1fe33dec4031fb9d8863a4874a8dfb60c4db79f9/rignore-0.8.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6012766ea3a5a635d9b79f3e8c3797d5e47ce5e5dd81993c9f92a3ea4ff68b4c", size = 885260, upload-time = "2026-08-04T22:24:20.236Z" }, + { url = "https://files.pythonhosted.org/packages/06/77/6ba8d24fd151513347d9420fb383ec732b54886c6a5500e13f0bd7ec4072/rignore-0.8.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90e60f0073caae0f1d59c993adedafa3a57bc6fac551669677cb25aa7fa9d9b8", size = 857548, upload-time = "2026-08-04T22:24:21.769Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/c54f448b077e89de99e85746677425fbb3a62d3721aeb9afa556dc3ca10f/rignore-0.8.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:121ab7ac93e39fd1d70098461c1ed9a6fb89d54e7bf8c60ae351b23b05cbb8c1", size = 1135817, upload-time = "2026-08-04T22:24:23.179Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/4b47da84a36687f10c09b43de7600d7dde8f56f2981e68b8822cfadd26aa/rignore-0.8.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:befb772556c8463c640b290f632b57440182edd39996708a33c50bcf437796f9", size = 913513, upload-time = "2026-08-04T22:24:24.719Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a4/8428734c0217b5c0adbe752afc9603ca34434c6033584f63d6ca7ba50331/rignore-0.8.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a184fa45db8cdc7a8604d2df020a107be3fc0adc2e522b4e4eb5cd5b57d5f84", size = 929324, upload-time = "2026-08-04T22:24:26.059Z" }, + { url = "https://files.pythonhosted.org/packages/da/f6/ab3738e42d9d351033b8e8fb46ac4b8eb193545aaf3ed2f52f58fb8d5212/rignore-0.8.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e68a572efc126aa45195f1581a5ea97c4e36eeba6874a6635ae44daa4fbec7a4", size = 891130, upload-time = "2026-08-04T22:24:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/9f374cf332eeea0e26cca6b6550678d0c69208e7ba4aea8ece0dc6b205eb/rignore-0.8.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c75ac1952ca8892422de328a925f4804120b149543042313bb3af7cbfdd65d64", size = 964769, upload-time = "2026-08-04T22:24:28.688Z" }, + { url = "https://files.pythonhosted.org/packages/37/46/7ee48265e34cb90ef4bcf85c63874da6d6a31af1c63fb7bca84fd5dbc188/rignore-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3538084cef4a66ba3fee7c453d17db7cfb32a6653456381b62afd8d53090d6fa", size = 1061779, upload-time = "2026-08-04T22:24:30Z" }, + { url = "https://files.pythonhosted.org/packages/01/23/51c725ba23f06b2809a0a676127ca3613e852bcab0532e58cab9f09b6fbb/rignore-0.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:28a3baab3d1b7ea42c38e492d4a100de3e5c67217432364c62b5719e0f04e96a", size = 1132595, upload-time = "2026-08-04T22:24:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a0/671192e2cc8711694cf564f77e9e59d5e8c41f100bce67fbb384f2befec5/rignore-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74996d8ed1a494ff8e61d9194a0dd3637e6b19a582cbed6fe0e1a4cc60e7b266", size = 1141117, upload-time = "2026-08-04T22:24:33.148Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/b3a9a821b37361debc29fd009d21dc4af7f8313b76371c1640aff2765a59/rignore-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a2e5df9ba53e502f676b054c4d12371273204e2b9c6ec29afad3a155b9ad3399", size = 1140515, upload-time = "2026-08-04T22:24:34.708Z" }, + { url = "https://files.pythonhosted.org/packages/0c/69/bcf0f38713c86d462002c47b0ad14d655f8766a3b9235d8f6813e8245695/rignore-0.8.1-cp314-cp314-win32.whl", hash = "sha256:69db47c2fa51d88e93b4e4de4f44220ce7a9337e4b7526a16f5660b19974a8ca", size = 637894, upload-time = "2026-08-04T22:24:36.081Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/0c0e0bb59aea492e288d5c058ad873599d43dd5674495033fe9e8444b80d/rignore-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:1c668a541ecd8af3d7bd06c48c62e1c3e0a755028d57c07cee697a25d9b0678d", size = 728394, upload-time = "2026-08-04T22:24:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/29/7c/c23926385e01d9b99fdf7bb7b86aaa60c2c1eb4de2a6303549f08497a0fa/rignore-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:d63559131efa9cbba82494ec8698e38bea403f214969066c661b3eb4da4ad4f9", size = 667896, upload-time = "2026-08-04T22:24:38.826Z" }, + { url = "https://files.pythonhosted.org/packages/38/9d/a70a895b47fa30446ffadd0a7add23494e18335ddc85c27cbc5a740374c7/rignore-0.8.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b60f3751f681a12798927d51d1d6a46b81fe7f9923a513dd8fcc7947d785e12f", size = 847278, upload-time = "2026-08-04T22:24:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/24fe37a08fd2083882422509c31bb58c14d85f2c7ab3b4ef3cc6f430d807/rignore-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7d56bf138418e31991ce17b738c491fa1ac098bfcc8c3ba67fc0faeecfed357", size = 815968, upload-time = "2026-08-04T22:24:41.975Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3d/1a260407a17e062859995d9f557b4ccbb36e6a0ef44a0d99409c836e5942/rignore-0.8.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f020b018577d0081a2b23da199d22c58f4cbd63935c8e991adffbb1a755b467", size = 884941, upload-time = "2026-08-04T22:24:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/70291634f2f2257fcabe1156926d7a10a87b0da1a8ba9ab2cafac37a4ded/rignore-0.8.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d4a70c9b857657c3a542fd367e16ae106fb6e3e4448cddb30e4edc604d5a025", size = 856667, upload-time = "2026-08-04T22:24:44.769Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/3811795d0b6fb3e8865d6a4f4eb3d3184e68a8557d7ee5b6d5a463196f54/rignore-0.8.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab668664e6388afc08fec50186ec21daf17c738ee71bb039d12cf5f14e964dde", size = 1134272, upload-time = "2026-08-04T22:24:46.213Z" }, + { url = "https://files.pythonhosted.org/packages/59/51/6a60ea8291346459e3f788bac4ecc9711b13271804c6eef47dc2ec59e6e7/rignore-0.8.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a3454cdd8bc145fd055dd45f650ccf1509e7b4edfc720152eb2be594b230f03", size = 914263, upload-time = "2026-08-04T22:24:47.604Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/620d4baa44e44ee53ae516efcb714183fd592ba923dc7990bf57947ff610/rignore-0.8.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:425b962f3d68b86ebb785409153708a38b0658542c918ee706ea433e2547c805", size = 928135, upload-time = "2026-08-04T22:24:48.987Z" }, + { url = "https://files.pythonhosted.org/packages/50/60/06495efc96c2edcda647dbf7db770ab9307459d3b2f8598d9ae1f1ce9ea5/rignore-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:5094965794d163e1c4a71dccf3d1c3b3df86802e3afbfa461b300aa923cd7ea5", size = 890677, upload-time = "2026-08-04T22:24:50.318Z" }, + { url = "https://files.pythonhosted.org/packages/ee/92/396f751e69d543bfb7e9faa5b0ae3ee988fcd76d9c743857fdb26db0651b/rignore-0.8.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1467756e8454d3f816131bbad8b0efc52b7f6924e9b151f2962cec9e2f4af706", size = 964291, upload-time = "2026-08-04T22:24:51.836Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0b/642483058dfbcbfc319d9cf48a05d8dbf9f7f6939b410eebdbc73dd9414c/rignore-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b5acf12993258f0eac4db81c30bed5469ebadd1b5bb2863c985fe0d802d94e0f", size = 1060972, upload-time = "2026-08-04T22:24:53.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/2d/69b54a53d94baf2fb16ea1246f96bf093aae6a0e5d5c5cf5099c1fee4fb8/rignore-0.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:329ae0bea9598541cb818bbecb1e1f56fc1faae77828ebb3846ef2b24a050d05", size = 1130938, upload-time = "2026-08-04T22:24:55.395Z" }, + { url = "https://files.pythonhosted.org/packages/bf/36/c213d838c3c2237da8c51163a6c55acaeedc59759d4884d81ab6d7250721/rignore-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4069fcdff01999cd2d5a426eccda45a8b31a3e0eeee8b5a9da5a452714cbb2c7", size = 1140646, upload-time = "2026-08-04T22:24:56.741Z" }, + { url = "https://files.pythonhosted.org/packages/96/70/28125520727d72e2878c1a6a3dbd333e57f27730b463d629d170e2da3b62/rignore-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf28097a83e1237f87e11e33fb12def022e103cab2a20340a19753e8e222aebf", size = 1139989, upload-time = "2026-08-04T22:24:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e6/8c0d1f2fa6d9b2f15e6a4bd20e7bfc8f4af436b8328ced782deda0a46057/rignore-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:c978f26c25e66c118da1e6218f34cc5ce2bf9c5cc3969011cde47bacbfe4a072", size = 640012, upload-time = "2026-08-04T22:24:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c4/ef25f5b8d65ddb0e5266b13892c0c996dc221faac5b76a7b6fff4295ce44/rignore-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:790e14dba9e1f8a532071d64e1c2fa7529c71db75996e95519d06d3378cbbcd5", size = 728195, upload-time = "2026-08-04T22:25:01.358Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6a/603b522d76a3acd66952e20e2ad7fa84c09d89436d707d6bb9ecb5ae55d3/rignore-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:484c0a4803a1eb9097d875df3200e60e58cbf316d4d40357f92e8e757ce529dd", size = 666282, upload-time = "2026-08-04T22:25:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fb/b8de8a872bdb9150aa55783273f73e9aa253951d8f82856fe916fce3cae8/rignore-0.8.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1c2a29e7f54e879fa54982887de480b2cfa6e3cb51f01d70dd4e4035f5b79670", size = 849669, upload-time = "2026-08-04T22:25:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/4ca7ce428378e7ef938cb36573f22e842dcdb2655e266b32711eea08d16f/rignore-0.8.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58449c827c36dba68133a5ee4137b67d7d6e050234a95c3a8f5e3cefbd6970ec", size = 817738, upload-time = "2026-08-04T22:25:05.489Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/78311079595c96e01a2404eff725468e25fad16d2836408046ffbdb33a69/rignore-0.8.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e250726b08957aabcf4e28aef4ea18bcccbd95f240cebb75a74b424f067dce4b", size = 885906, upload-time = "2026-08-04T22:25:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/fe/57/7d456d2dcc881e6bf08275222ae36fff58343ee0fc47e1b26722f97b38ea/rignore-0.8.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62470f4d70d83f124381975614ed7c7db6f5c5fb4d777ee88f7bdb9a7d14b65c", size = 858240, upload-time = "2026-08-04T22:25:08.371Z" }, + { url = "https://files.pythonhosted.org/packages/64/3c/0f9bd7b9b3af37b216096e1dc81f9368d64c13625ddd34b4d595b316b47c/rignore-0.8.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d886d6bbdd0a1a3ef73bd38d6768cabc9b30bfd1be7b157b27a4ac7ec6c5244b", size = 1135145, upload-time = "2026-08-04T22:25:09.81Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/f0087a520e3bf3d9532aa823ea141e5a076432b9d036bb506a99ddb534c2/rignore-0.8.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ed31657a20df59b0bd63283d278e8678749aa0154ba98fae0838a9845599695", size = 914331, upload-time = "2026-08-04T22:25:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/f0/65/8ac37040f162cb965520286ef48472a87f47cb137df4068a0d32634166dc/rignore-0.8.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e425a42f6601bfed266f76f3294bca48efcfdb10c3c0c279fb2f977b1e2cc2bc", size = 929202, upload-time = "2026-08-04T22:25:13.057Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/19b752fda8a56424ee156ef6a9799c582eb6d2ffb233c7b91e103b79d54e/rignore-0.8.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:ff09db59f672d929bca88ee7089d3697256967df76a2bab8b187208f2b517bc0", size = 891752, upload-time = "2026-08-04T22:25:14.773Z" }, + { url = "https://files.pythonhosted.org/packages/a9/94/a5db364ddb136360c7c4ba75cb56ba2f7cd8321b296602f6ea7438fc1a83/rignore-0.8.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8dbc5898945c027dc0ae451de0c983e20bd8fa8297f40ee38d0ce1a3ed924d4c", size = 964714, upload-time = "2026-08-04T22:25:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/f6/11/63316cd5a87402a42c75b6ada0331745975f0274cabb6a8aa85c043468ab/rignore-0.8.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1160abcd855964a9dd69f3d603ef57be14b1ea51ebaf50b07f737a3f3a8b89a6", size = 1062141, upload-time = "2026-08-04T22:25:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/91/6e/19b4bdde3f53bb0c9427d65544f92f864a892d695559423ce01fd14c1a31/rignore-0.8.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:be994859b2cbbc69338351bc9908dd7d049de232e6eab5e998ec1feac3faf785", size = 1133487, upload-time = "2026-08-04T22:25:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/fc41ea3164001fe84a2ec8b84d7bb48e7a73ebdcac6d68961c80f874dd4e/rignore-0.8.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:f52098f3245c7557229d8253ea3a53a03de03704685aba8a1ccd24a5f004db70", size = 1141434, upload-time = "2026-08-04T22:25:21.316Z" }, + { url = "https://files.pythonhosted.org/packages/34/35/b9d415a7e90c37b28b2d5fd6a074a4a3d572d5a0e0b596df0b4b85af8a78/rignore-0.8.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c676080bb70cbd5429052bb5a3827b35543876a42c792c6d713d9e162bdaa00d", size = 1140492, upload-time = "2026-08-04T22:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/81/9b/49ac5f66862c796a04f0b7aad47ace4dea0a3ef52d313d2fb154821dd88a/rignore-0.8.1-cp315-cp315-win32.whl", hash = "sha256:ec800546b960d5044d2468d22dd0689210e846350c42cc9f02519c2a2528b3ef", size = 638365, upload-time = "2026-08-04T22:25:24.34Z" }, + { url = "https://files.pythonhosted.org/packages/38/76/90568f3af555074c5e1936b80573539658cda1dce4e89dd87f3174d88aa8/rignore-0.8.1-cp315-cp315-win_amd64.whl", hash = "sha256:4225f2d2f0b3e3c39a815f15dbe551f39f0d7f82e2a92d16bdbdb07ff4b6718e", size = 728711, upload-time = "2026-08-04T22:25:25.815Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/3afb8bb7c6c538615d58c97f6e3b9ff7071f19442cd9cfb6f3ae6bbe0bee/rignore-0.8.1-cp315-cp315-win_arm64.whl", hash = "sha256:42480707cdd5f92d6b1252faada600e03ab22fbfd53b124c6f7b33c21e06b6cf", size = 668102, upload-time = "2026-08-04T22:25:27.251Z" }, + { url = "https://files.pythonhosted.org/packages/26/08/ce533f5c0b677dd7e82a3461e86afdf0cd99db9a73f3f561d1fabcd03fc8/rignore-0.8.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:51e608acd3c1aa834f2b7d956ff5380a0a6b6b595df80e665f2a2889982f1855", size = 846662, upload-time = "2026-08-04T22:25:28.718Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/4bb308ac5751881d7ba600e93d7f2e4a2c884af5028ac0ced4f1274c09c3/rignore-0.8.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6602011b6e6ecd157f3b0a0a25b1777b40c541ca21de6ebe2137af713d0efa15", size = 816657, upload-time = "2026-08-04T22:25:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f1/10a11bf9ebf03f7a8d9876ff023634cd4ce61845be9929f816ece4dfff11/rignore-0.8.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8db2be0d49175d5db4cd503f8dc62cbe687b9ebc71af4cede1c8b40bab8f4f4a", size = 884975, upload-time = "2026-08-04T22:25:31.906Z" }, + { url = "https://files.pythonhosted.org/packages/1b/97/c8dddb2dab738bd1b3a0b1639bc42d696bdd2765515a302620a35795c475/rignore-0.8.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:601ff49d8458a21d745908e35f792c02b45ecae4a37fa3cd1fc03fa065962bc0", size = 856951, upload-time = "2026-08-04T22:25:33.354Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1d/ddcc1cddfb0a79c82eedbcd25ba0b014d346251cc08c1938b90951660cf8/rignore-0.8.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb58e93997d546fad4cfcd730a54d7c12cd7a61fe7a9b31bfc75402403dc559f", size = 1134401, upload-time = "2026-08-04T22:25:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/519d2e081e0b89c705bb4b282ed7540c185accc748c97c40dd80a333eb3a/rignore-0.8.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0378cf77b8d64560e0cb433deada18438b36ef7933dd284dd65347d03c56c429", size = 914389, upload-time = "2026-08-04T22:25:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ed/aa/c1e857c4b8f9dd388c38c8ca430917f24f1de44d9279d4f5d9edbcc94a0c/rignore-0.8.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0097a8c35106997d2b52851f0888777efe10e34772140a9fdb018b2f99238159", size = 928845, upload-time = "2026-08-04T22:25:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9c/a9651d49fbf2a336074df0a09d01ee2d968dae902e88e51e32d822f5e9fc/rignore-0.8.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2a809a250f1532b93dcc52e173ee71adcafdb536a125532687666047c4537ac0", size = 891090, upload-time = "2026-08-04T22:25:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/53/98/d3242c45edfdc059fb80b73be9e27dac34639da40002ebbcd5c9b9acb821/rignore-0.8.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:580e019787032b430b857335a66db53d7ae0a200586b3a6b5d3ef227648300d6", size = 965283, upload-time = "2026-08-04T22:25:41.796Z" }, + { url = "https://files.pythonhosted.org/packages/56/37/e19e70395c2e5a06ba67e0a51e86ede1b4269b8d3ba091963c4f0a950f63/rignore-0.8.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f922dcf01e6a7ad26adfcb7f42635ae63066b883d409876129db62297061327e", size = 1061223, upload-time = "2026-08-04T22:25:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bd/24b737f97c542e4883cfcc557b08565adc7c698e30836b8bc9855676d749/rignore-0.8.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4900bd1ba8938e5a5e601306504c7b8169783a2d44a8396c8fda4a9b659eed51", size = 1131772, upload-time = "2026-08-04T22:25:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8d/bedc51ca98696998797af67163c039e0adc7835035fb929f10d33fb3d997/rignore-0.8.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:3d581bf107490abae4d40a9aa823a79e18c88a21658514ccaf95e42e85d278f7", size = 1141631, upload-time = "2026-08-04T22:25:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9c/946e55a7e5bc7b165faed3475451683580ddf8b45d63e67c39a0cdee5ce2/rignore-0.8.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:78490b93aec14a87fa4c23e634b1d1dcb2bf5dcb90923f3b03a3428892a13d9f", size = 1140328, upload-time = "2026-08-04T22:25:48.447Z" }, + { url = "https://files.pythonhosted.org/packages/b9/02/211daf0a7f957386d2223ef7a1601d36b42a6c1a6b93aae32db712868390/rignore-0.8.1-cp315-cp315t-win32.whl", hash = "sha256:e8315151a83b982fe972420372e80c550616095181d1549faaa1ec31f363f5ae", size = 640253, upload-time = "2026-08-04T22:25:50.025Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0e/ccb682455ca380cc640c2a9536390fd907cc5ac0396118643f9d386e4af5/rignore-0.8.1-cp315-cp315t-win_amd64.whl", hash = "sha256:69be37202a052d9e13affed6b774f86d8bf3b417b15d54ad737033e56da07ec0", size = 728332, upload-time = "2026-08-04T22:25:51.937Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ec/a2b634938c39600205dcd37015343e779dc8f112c234d905666dec2a66fd/rignore-0.8.1-cp315-cp315t-win_arm64.whl", hash = "sha256:4268e83bcb88f240ed4f43d73e7de9e4bcd8026bdfcfa836567d6c29863e5361", size = 666611, upload-time = "2026-08-04T22:25:53.857Z" }, + { url = "https://files.pythonhosted.org/packages/07/15/487adc641ff0d2bfebd0add41029c9d598f794346097e65e5e16dc8063f2/rignore-0.8.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6e658a26627f27e15ec54027f051c50818c86b60f940cc20991054e00f5c0152", size = 849492, upload-time = "2026-08-04T22:26:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/22/a2/04acd232ac7eab4ff3d41bd83bcf4affc130b5997ce504a4b93d1ac43cb4/rignore-0.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1867356484fdc502695f71c2c986cde702e2a14d6db76ca3837a6f066cafb93c", size = 826012, upload-time = "2026-08-04T22:26:46.982Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7a/ba90f5ed9849b5787f7d8341ee7ee5b9f960947cd651da713c764f7b5c54/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a82567032d81f559ba4a9f3e96ffcb48ae149dff2f44c58c966b5890eb248895", size = 889243, upload-time = "2026-08-04T22:26:48.719Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/23de6c34932b145e66de124efa6384c7ebe31e38402e1aabbab6588b94c6/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9a9812dc25dcfb9bf2e546247f6e38b0c29a0d39864eb62de2a48b2ba41b961", size = 862703, upload-time = "2026-08-04T22:26:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/78a8e0380fefdaada4663d17098a989e43312c1efa83f19ab21bb0338213/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c735c5586f4306d9a62ad374093d6127b24ddadeaac322684e3e4c923eeb6a58", size = 1140600, upload-time = "2026-08-04T22:26:51.884Z" }, + { url = "https://files.pythonhosted.org/packages/fc/51/2bacec13cde1d9ec7113f133ed2ecbf45f9e8fbd53a7434e1fee5d16d664/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a249e142d2d313872e526949b9cdc5363041d64531f53553e702c00747225dc2", size = 920069, upload-time = "2026-08-04T22:26:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/61/10/befc052ff61a070dfb86330bc0e9b3860d2d1386c30b76ec527dc50390b2/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe7a7dcd84affbf312336991202c11922c13aff323e2857c5baf10afc0dd7f2d", size = 934482, upload-time = "2026-08-04T22:26:55.389Z" }, + { url = "https://files.pythonhosted.org/packages/a5/45/22e55ed298df00e15e851489750ed509ca9d2677f7c2cb9474edb8392ce2/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:08acabc3203c68a8bed7689c50b546a4ad7c9144ced2199c1261a149f7a3cef2", size = 900919, upload-time = "2026-08-04T22:26:57.564Z" }, + { url = "https://files.pythonhosted.org/packages/23/bc/fcfa184912efb34b2d1cb1938d6f96550e20a06b89ea75e3b51238a48cde/rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4862e015736af87a4afa1a75023d28363ee34449928f8f5b521d1f9eabb0c826", size = 971779, upload-time = "2026-08-04T22:26:59.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/d1a2071f9c908309c01ee53958ff1e4b81852671a32f206dc908bbf5519e/rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c96c3a434b50a7663e011cf6060b0f9ba44fd956b310b230bbca9dd665bb1f53", size = 1066847, upload-time = "2026-08-04T22:27:01.224Z" }, + { url = "https://files.pythonhosted.org/packages/3d/73/463687fb803db4c97fbe3d54354a8030b908e9685f29f4556335dc65c94c/rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:4cc3003a462778a75aadabde87edebc0e1d205c119c703c155bb03a4f3e8be23", size = 1137382, upload-time = "2026-08-04T22:27:02.931Z" }, + { url = "https://files.pythonhosted.org/packages/70/6f/6d90ff36a046d2d05c55c9f8d6287e3d5a8ebe0fddd50dce8872019d4462/rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0dc7765f94d6660574d648c89e28c9932248499d47dad49c2786f2e49231e1d9", size = 1147729, upload-time = "2026-08-04T22:27:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/d2c11ed977298cee41c23b6625da9f816ba9f7b91005dfb6674cc0bec09c/rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7b929db3f8f3fa240b0f328453de0b1b747e51b7992a9198c8412d16f4f0027", size = 1144578, upload-time = "2026-08-04T22:27:06.473Z" }, +] + +[[package]] +name = "rlp" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/45/68859ee36a69ddd8fa819d52fab75214de56f05500907e8bf09b5fd7bd75/rlp-5.0.0.tar.gz", hash = "sha256:ae8ac791160c160e270f9c7df76e68f4d42bb86a13726d807b9357c312d0bac4", size = 46769, upload-time = "2026-08-21T21:48:51.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/35/26ea7adca149e506bb03c50ade0c4d96cf269e6fc13a2b3f726d65905ffb/rlp-5.0.0-py3-none-any.whl", hash = "sha256:9525fb895d3a6540b7401eadd11c1b64740a5eccb82e543ac7e9735d319b0977", size = 19832, upload-time = "2026-08-21T21:48:50.355Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "solana" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "aiolimiter", marker = "python_full_version < '3.11'" }, + { name = "construct-typing", marker = "python_full_version < '3.11'" }, + { name = "httpx2", extra = ["http2"], marker = "python_full_version < '3.11'" }, + { name = "pydantic", marker = "python_full_version < '3.11'" }, + { name = "solders", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "websockets", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/74/9f0621b79059d1e8c010d63514316fb691d94f0c408a215fb71173c5a896/solana-0.38.0.tar.gz", hash = "sha256:ca2c6441483ce5c0ce57d3538ae35cbe04e9c79e6cfc76942799746bfd9ff2d6", size = 498247, upload-time = "2026-06-22T01:42:08.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/1d/4f1d03735b33f32f5223026c45240c61c0730de598f78f1dcbb129389443/solana-0.38.0-py3-none-any.whl", hash = "sha256:396360b01b7ec463c3c92f25450eaf264202fd629f78054bb935ebb0a0410ff5", size = 73328, upload-time = "2026-06-22T01:42:07.509Z" }, +] + +[[package]] +name = "solana" +version = "0.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +dependencies = [ + { name = "aiolimiter", marker = "python_full_version >= '3.11'" }, + { name = "construct-typing", marker = "python_full_version >= '3.11'" }, + { name = "httpx2", extra = ["http2"], marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "solders", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "websockets", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/a2/b99cd8e0268a52298156341b27dc7001b5e62469774628be4cda6da52283/solana-0.39.0.tar.gz", hash = "sha256:76e86486ff81db54a418d018b4b333794652097d166d113e6c239234a9922787", size = 488216, upload-time = "2026-06-23T03:09:42.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/c6/ed3b7056f84b6acdd81baf78bd9b5abaf8a932e58d5609adb10f274274fc/solana-0.39.0-py3-none-any.whl", hash = "sha256:a06adf7ea243624d18b49079656ea667944da2fbb98a0c2f841290b81f571a7f", size = 72060, upload-time = "2026-06-23T03:09:41.411Z" }, +] + +[[package]] +name = "solders" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonalias" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/6b/0c0ee4766705824261779d00229fb95308d6b28422613e0e2af577f60ee3/solders-0.27.1-cp38-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4dcd8e766bab24afbe9e0ae363d86f9810457e04b00c8a9149f69ca939ed587c", size = 24883435, upload-time = "2025-11-15T07:50:34.42Z" }, + { url = "https://files.pythonhosted.org/packages/33/1c/be04a1b26e18c409dd006d214198dc03f0b657c1cb34f4c83b763f8348f0/solders-0.27.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d87b145cc0129095f9cff8c7f28d2e910bc5b5a4cf257c263b08a4b95f111dd", size = 6480729, upload-time = "2025-11-15T07:50:37.323Z" }, + { url = "https://files.pythonhosted.org/packages/48/03/98dc73c266b11ed5c13b3933510a1aa115becf97f45bec1a22da9d03ffa9/solders-0.27.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6082bbe46b7b1b2b005d046011f89fcae75fc5ea4f1a0ef5c2e9dfb5fe7930ce", size = 12744782, upload-time = "2025-11-15T07:50:39.283Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/35384d8fb80d05937bd9e8af7237cfe3f0d017c8aba357209d90d428f3a0/solders-0.27.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ccb821c2e4af43d976f312086f248a67352b3986e5f4c87af41cfeac6d8b5683", size = 6601257, upload-time = "2025-11-15T07:50:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/8c/65/8989e521142473bf1130613476a4449e106bb97ed6cc86097f6f519b1234/solders-0.27.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:663a10566ae81f67c4515d4db5fbf51b735204741728c1a5cde11c4e019a51df", size = 7277802, upload-time = "2025-11-15T07:50:43.789Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/87ecf12cec0e7aa9c67b0cf1b8079fb28aa0af91e97328a3bd0c5e3001ba/solders-0.27.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d14f05a77dbbf7966fb26f255c81302e6127550bdb66c2fdc99f522043fdf376", size = 7082541, upload-time = "2025-11-15T07:50:45.847Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/35e6f59b41bb205b26c7318fcdca43f3d59464fd3ddc13d36f36427f64d4/solders-0.27.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f778eeab411acec0a765a01c7b772f8eca8a8543d98276bd83cb826960da211b", size = 6845568, upload-time = "2025-11-15T07:50:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "web3" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "eth-abi" }, + { name = "eth-account" }, + { name = "eth-hash", extra = ["pycryptodome"] }, + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "pyunormalize" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/bdfa9e715804020c3f3676346065c18adbc207c9343a3458246d7430f45c/web3-7.16.0.tar.gz", hash = "sha256:b4a75a3fa94fef4d23d502eb3c2244146ef9a1ee0082cf1cb0a91586ba0510c3", size = 2211469, upload-time = "2026-05-01T21:22:20.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/f9/5345c13f8469f3ce344b4d9934c0387b83a49420192482111e9c1fa95ec2/web3-7.16.0-py3-none-any.whl", hash = "sha256:760b2718c473980d70708c3593d9d28395db4b482f45e38a63a36fa028178f51", size = 1372181, upload-time = "2026-05-01T21:22:17.015Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "x402" +version = "2.20.0" +source = { editable = "../../../../python/x402" } +dependencies = [ + { name = "nest-asyncio" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +evm = [ + { name = "eth-abi" }, + { name = "eth-account" }, + { name = "eth-keys" }, + { name = "eth-utils" }, + { name = "web3" }, +] +fastapi = [ + { name = "fastapi", extra = ["standard"] }, + { name = "starlette" }, +] +svm = [ + { name = "solana", version = "0.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "solana", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "solders" }, +] + +[package.metadata] +requires-dist = [ + { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, + { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.13.0" }, + { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, + { name = "eth-utils", marker = "extra == 'evm'", specifier = ">=4.0.0" }, + { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, + { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, + { name = "idna", marker = "extra == 'extensions'", specifier = ">=3.4" }, + { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, + { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'extensions'", specifier = ">=1.5.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, + { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, + { name = "signinwithethereum", marker = "extra == 'extensions'", specifier = ">=5.0.1" }, + { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0,<0.40" }, + { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, + { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, + { name = "typing-extensions", specifier = ">=4.0.0" }, + { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, + { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, +] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=23.0.0" }, + { name = "eth-abi", specifier = ">=5.0.0" }, + { name = "eth-account", specifier = ">=0.13.0" }, + { name = "eth-keys", specifier = ">=0.5.0" }, + { name = "eth-utils", specifier = ">=4.0.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, + { name = "flask", specifier = ">=3.0.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = ">=3.4" }, + { name = "jsonschema", specifier = ">=4.0.0" }, + { name = "mcp", specifier = ">=1.26.0" }, + { name = "mypy", specifier = ">=1.0.0" }, + { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "ruff", specifier = ">=0.1.0" }, + { name = "signinwithethereum", specifier = "==5.0.1" }, + { name = "solana", specifier = ">=0.36.0,<0.40" }, + { name = "solders", specifier = ">=0.27.0" }, + { name = "starlette", specifier = ">=0.27.0" }, + { name = "towncrier", specifier = ">=24.8.0,<25" }, + { name = "web3", specifier = ">=7.0.0" }, +] + +[[package]] +name = "x402-upfront-fastapi-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dotenv" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "x402", extra = ["evm", "fastapi", "svm"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, + { name = "x402", extras = ["fastapi", "evm", "svm"], editable = "../../../../python/x402" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=23.0.0" }, + { name = "mypy", specifier = ">=1.0.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "ruff", specifier = ">=0.1.0" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] diff --git a/examples/typescript/README.md b/examples/typescript/README.md index e2df2a8a1c..013b9cf9b9 100644 --- a/examples/typescript/README.md +++ b/examples/typescript/README.md @@ -48,6 +48,7 @@ Servers that put a paywall in front of a resource. See [`servers/`](./servers/). | [`advanced/`](./servers/advanced/) | Dynamic pricing, payment routing, lifecycle hooks, discoverability | | [`custom/`](./servers/custom/) | Manual payment handling without an x402 middleware package | | [`self-facilitation/`](./servers/self-facilitation/) | In-process `x402Facilitator` instead of an external facilitator URL | +| [`upfront/`](./servers/upfront/) | `exact` scheme with `upfront` payment flow (settle before handler) | | [`upto/`](./servers/upto/) | `upto` scheme: authorize a ceiling, settle only actual usage | | [`batch-settlement/`](./servers/batch-settlement/) | Off-chain vouchers claimed and settled in batches by a `ChannelManager` | | [`bazaar/`](./servers/bazaar/) | Makes a paid API discoverable via the Bazaar extension | diff --git a/examples/typescript/clients/README.md b/examples/typescript/clients/README.md index 007280215c..9ff460aeff 100644 --- a/examples/typescript/clients/README.md +++ b/examples/typescript/clients/README.md @@ -6,11 +6,10 @@ This directory contains TypeScript client examples demonstrating how to make HTT | Directory | Description | | --- | --- | -| [`fetch/`](./fetch/) | Using `@x402/fetch` with the native Fetch API | -| [`axios/`](./axios/) | Using `@x402/axios` with Axios | +| [`fetch/`](./fetch/) | Using `@x402/fetch` with the native Fetch API (exact, upto, auth-capture, and SVM schemes) | +| [`axios/`](./axios/) | Using `@x402/axios` with Axios (exact, upto, auth-capture, and SVM schemes) | | [`advanced/`](./advanced/) | Advanced patterns: lifecycle hooks, network preferences, spend controls | | [`custom/`](./custom/) | Manual implementation without `@x402/fetch` or `@x402/axios` | -| [`auth-capture/`](./auth-capture/) | Pays an auth-capture endpoint by signing an ERC-3009 `ReceiveWithAuthorization` | | [`batch-settlement/`](./batch-settlement/) | Pays a sequence of requests over one payment channel using cumulative vouchers | | [`builder-code/`](./builder-code/) | Verifies ERC-8021 builder-code attribution on the settlement transaction | | [`erc7702/`](./erc7702/) | Paying from an ERC-7702 delegated EOA | diff --git a/examples/typescript/clients/axios/README.md b/examples/typescript/clients/axios/README.md index e493744b17..a21bbad923 100644 --- a/examples/typescript/clients/axios/README.md +++ b/examples/typescript/clients/axios/README.md @@ -4,12 +4,16 @@ Example client demonstrating how to use `@x402/axios` to make HTTP requests to e ```typescript import { x402Client, wrapAxiosWithPayment } from "@x402/axios"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { UptoEvmScheme } from "@x402/evm/upto/client"; import { privateKeyToAccount } from "viem/accounts"; import axios from "axios"; const client = new x402Client() .register("eip155:*", new ExactEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) + .register("eip155:*", new UptoEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) + .register("eip155:*", new AuthCaptureEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) .setSpendControls({ maxAmountPerPayment: "$1", }); diff --git a/examples/typescript/clients/axios/index.ts b/examples/typescript/clients/axios/index.ts index a6b591b507..b40525ae3f 100644 --- a/examples/typescript/clients/axios/index.ts +++ b/examples/typescript/clients/axios/index.ts @@ -1,5 +1,6 @@ import { config } from "dotenv"; import { x402Client, wrapAxiosWithPayment, x402HTTPClient } from "@x402/axios"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -36,6 +37,7 @@ async function main(): Promise { const client = new x402Client() .register("eip155:*", new ExactEvmScheme(evmSigner, rpcOptions)) .register("eip155:*", new UptoEvmScheme(evmSigner, rpcOptions)) + .register("eip155:*", new AuthCaptureEvmScheme(evmSigner)) .register("solana:*", new ExactSvmScheme(svmSigner)) .register("solana:*", new UptoSvmScheme(svmSigner)) .setSpendControls({ diff --git a/examples/typescript/clients/fetch/README.md b/examples/typescript/clients/fetch/README.md index d68d5d500e..63d94c98ca 100644 --- a/examples/typescript/clients/fetch/README.md +++ b/examples/typescript/clients/fetch/README.md @@ -4,7 +4,9 @@ Example client demonstrating how to use `@x402/fetch` to make HTTP requests to e ```typescript import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { UptoEvmScheme } from "@x402/evm/upto/client"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { privateKeyToAccount } from "viem/accounts"; import { createKeyPairSignerFromBytes } from "@solana/kit"; @@ -12,6 +14,8 @@ import { base58 } from "@scure/base"; const client = new x402Client() .register("eip155:*", new ExactEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) + .register("eip155:*", new UptoEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) + .register("eip155:*", new AuthCaptureEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY))) .register("solana:*", new ExactSvmScheme(await createKeyPairSignerFromBytes(base58.decode(process.env.SVM_PRIVATE_KEY)))) .setSpendControls({ maxAmountPerPayment: "$1", diff --git a/examples/typescript/clients/fetch/index.ts b/examples/typescript/clients/fetch/index.ts index 3ac9fea7f2..14eead9b6e 100644 --- a/examples/typescript/clients/fetch/index.ts +++ b/examples/typescript/clients/fetch/index.ts @@ -1,5 +1,6 @@ import { config } from "dotenv"; import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -42,6 +43,7 @@ async function main(): Promise { const rpcOptions = evmRpcUrl ? { rpcUrl: evmRpcUrl } : undefined; client.register("eip155:*", new ExactEvmScheme(evmSigner, rpcOptions)); client.register("eip155:*", new UptoEvmScheme(evmSigner, rpcOptions)); + client.register("eip155:*", new AuthCaptureEvmScheme(evmSigner)); } if (svmPrivateKey) { const svmSigner = await createKeyPairSignerFromBytes(base58.decode(svmPrivateKey)); @@ -53,9 +55,11 @@ async function main(): Promise { const httpClient = new x402HTTPClient(client); console.log(`Making request to: ${url}\n`); + const requestT0 = performance.now(); const response = await fetchWithPayment(url, { method: "GET" }); const result = await httpClient.processResponse(response); console.dir(result, { depth: null }); + console.log(`Request completed in ${((performance.now() - requestT0) / 1000).toFixed(3)}s`); } main().catch(error => { diff --git a/examples/typescript/facilitator/basic/index.ts b/examples/typescript/facilitator/basic/index.ts index c1b91520bc..e47be1f7ad 100644 --- a/examples/typescript/facilitator/basic/index.ts +++ b/examples/typescript/facilitator/basic/index.ts @@ -94,7 +94,11 @@ const evmSigner = toFacilitatorEvmSigner({ }); // Facilitator can now handle all Solana networks with automatic RPC creation -const svmSigner = toFacilitatorSvmSigner(svmAccount); +const svmRpcUrl = process.env.SVM_RPC_URL; +const svmSigner = toFacilitatorSvmSigner( + svmAccount, + svmRpcUrl ? { defaultRpcUrl: svmRpcUrl } : undefined, +); const facilitator = new x402Facilitator() .onBeforeVerify(async (context) => { @@ -142,6 +146,7 @@ app.use(express.json()); * Note: Payment tracking and bazaar discovery are handled by lifecycle hooks */ app.post("/verify", async (req, res) => { + const endpointT0 = performance.now(); try { const { paymentPayload, paymentRequirements } = req.body as { paymentPayload: PaymentPayload; @@ -168,6 +173,10 @@ app.post("/verify", async (req, res) => { res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error", }); + } finally { + console.log( + `/verify completed in ${((performance.now() - endpointT0) / 1000).toFixed(3)}s`, + ); } }); @@ -178,6 +187,7 @@ app.post("/verify", async (req, res) => { * Note: Verification validation and cleanup are handled by lifecycle hooks */ app.post("/settle", async (req, res) => { + const endpointT0 = performance.now(); try { const { paymentPayload, paymentRequirements } = req.body; @@ -216,6 +226,10 @@ app.post("/settle", async (req, res) => { res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error", }); + } finally { + console.log( + `/settle completed in ${((performance.now() - endpointT0) / 1000).toFixed(3)}s`, + ); } }); diff --git a/examples/typescript/facilitator/upto/index.ts b/examples/typescript/facilitator/upto/index.ts index 655801a1a7..92214b96b5 100644 --- a/examples/typescript/facilitator/upto/index.ts +++ b/examples/typescript/facilitator/upto/index.ts @@ -148,11 +148,13 @@ if (svmPrivateKey) { ); console.info(`SVM Facilitator account: ${svmAccount.address}`); - const svmSigner = toFacilitatorSvmSigner(svmAccount); + const svmSigner = toFacilitatorSvmSigner( + svmAccount, + svmRpcUrl ? { defaultRpcUrl: svmRpcUrl } : undefined, + ); const svmUptoScheme = new UptoSvmScheme(svmSigner, { channelStorage, maxChannelLifetimeSecs, - rpcUrl: svmRpcUrl, }); facilitator.register(SVM_NETWORK, svmUptoScheme); diff --git a/examples/typescript/fullstack/miniapp/README.md b/examples/typescript/fullstack/miniapp/README.md index f4bc643763..980ed4b6ab 100644 --- a/examples/typescript/fullstack/miniapp/README.md +++ b/examples/typescript/fullstack/miniapp/README.md @@ -67,7 +67,7 @@ NEXT_PUBLIC_ICON_URL=https://example.com/app-logo.png ### Server-Side Payment Protection -The `/api/protected` endpoint uses the `withX402` wrapper for payment protection: +The `/api/protected` endpoint uses the `withX402` wrapper for payment protection. This is a **static** route, so a bare route config (no path key) is sufficient: ```typescript // app/api/protected/route.ts @@ -99,6 +99,8 @@ export const GET = withX402( ); ``` +For **dynamic** routes (`/api/users/[id]`, etc.) or bazaar discovery with an explicit catalog path, key the config by pattern: `{ "/api/users/[id]": { accepts: … } }`. See [`@x402/next` README](../../../../typescript/packages/http/next/README.md#protecting-api-routes). + ### Client-Side Payment Handling The frontend uses `@x402/fetch` to handle payments: @@ -211,7 +213,7 @@ The `PAYMENT-REQUIRED` header contains payment requirements: ### Adding More Protected Routes -Create a new route file (e.g., `app/api/premium/route.ts`) and use the `withX402` wrapper: +Create a new route file (e.g., `app/api/premium/route.ts`) and use the `withX402` wrapper. Static paths can use a bare config; dynamic paths need a path key — see [`@x402/next` README](../../../../typescript/packages/http/next/README.md#protecting-api-routes). ```typescript // app/api/premium/route.ts diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/README.md b/examples/typescript/fullstack/next-batch-settlement-redis/README.md index 959e3255ed..a46c8531a6 100644 --- a/examples/typescript/fullstack/next-batch-settlement-redis/README.md +++ b/examples/typescript/fullstack/next-batch-settlement-redis/README.md @@ -4,7 +4,7 @@ Next.js demo that exposes **`GET /api/weather`** behind `withX402` with the **ba Parallels: -- Response shape / `withX402` usage: `examples/typescript/fullstack/next/app/api/weather/route.ts` +- Response shape / `withX402` usage: `examples/typescript/fullstack/next/app/api/weather/route.ts` (path-keyed config + bazaar discovery; static routes can use a bare config instead) - Batch-settlement Express example wiring: `examples/typescript/servers/batch-settlement/index.ts` - Batch-settlement client: `examples/typescript/clients/batch-settlement` diff --git a/examples/typescript/fullstack/next/README.md b/examples/typescript/fullstack/next/README.md index 61431c2c8c..fb463f3e05 100644 --- a/examples/typescript/fullstack/next/README.md +++ b/examples/typescript/fullstack/next/README.md @@ -104,12 +104,13 @@ export const config = { ### Weather API Route (using withX402) -The `/api/weather` route demonstrates the `withX402` wrapper for individual API routes: +The `/api/weather` route demonstrates the `withX402` wrapper for individual API routes. It uses bazaar discovery, so the route config is keyed by path (`/api/weather`). For a static route without discovery, a bare config (no path key) is enough — see [`@x402/next` README](../../../../typescript/packages/http/next/README.md#protecting-api-routes). ```typescript // app/api/weather/route.ts import { NextRequest, NextResponse } from "next/server"; import { withX402 } from "@x402/next"; +import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; import { server, paywall, evmAddress, svmAddress } from "../../../proxy"; const handler = async (_: NextRequest) => { @@ -124,22 +125,33 @@ const handler = async (_: NextRequest) => { export const GET = withX402( handler, { - accepts: [ - { - scheme: "exact", - price: "$0.001", - network: "eip155:84532", - payTo: evmAddress, - }, - { - scheme: "exact", - price: "$0.001", - network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - payTo: svmAddress, + "/api/weather": { + accepts: [ + { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + }, + { + scheme: "exact", + price: "$0.001", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: svmAddress, + }, + ], + description: "Access to weather API", + mimeType: "application/json", + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + report: { weather: "sunny", temperature: 72 }, + }, + }, + }), }, - ], - description: "Access to weather API", - mimeType: "application/json", + }, }, server, undefined, // paywallConfig (using custom paywall from proxy.ts) @@ -234,6 +246,8 @@ The `withX402` function wraps API route handlers. This is the recommended approa | `paymentProxy` | Protecting page routes or multiple routes with a single configuration | | `withX402` | Protecting individual API routes where you need precise control over settlement timing | +For `withX402`, pass a **bare route config** on static routes; key by path pattern (e.g. `{ "/api/users/[id]": config }`) when the URL has dynamic segments or you use bazaar discovery and want an explicit catalog path. Details: [`@x402/next` README](../../../../typescript/packages/http/next/README.md#protecting-api-routes). + ## Extending the Example To add more protected routes, update the proxy configuration: diff --git a/examples/typescript/fullstack/next/app/api/weather/route.ts b/examples/typescript/fullstack/next/app/api/weather/route.ts index 1fd9cd201a..b88a0b2f0e 100644 --- a/examples/typescript/fullstack/next/app/api/weather/route.ts +++ b/examples/typescript/fullstack/next/app/api/weather/route.ts @@ -34,33 +34,35 @@ const handler = async (_: NextRequest) => { export const GET = withX402( handler, { - accepts: [ - { - scheme: "exact", - price: "$0.001", - network: "eip155:84532", // base-sepolia - payTo: evmAddress, - }, - { - scheme: "exact", - price: "$0.001", - network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // solana devnet - payTo: svmAddress, - }, - ], - description: "Access to weather API", - mimeType: "application/json", - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - report: { - weather: "sunny", - temperature: 72, + "/api/weather": { + accepts: [ + { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", // base-sepolia + payTo: evmAddress, + }, + { + scheme: "exact", + price: "$0.001", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // solana devnet + payTo: svmAddress, + }, + ], + description: "Access to weather API", + mimeType: "application/json", + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + report: { + weather: "sunny", + temperature: 72, + }, }, }, - }, - }), + }), + }, }, }, server, diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 6a19d565bd..a9e8011f7d 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1247,6 +1247,9 @@ importers: ../../typescript/packages/mechanisms/svm: dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@solana-program/compute-budget': specifier: ^0.11.0 version: 0.11.0(@solana/kit@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -2524,10 +2527,10 @@ importers: dependencies: '@farcaster/miniapp-sdk': specifier: 0.2.1 - version: 0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@farcaster/miniapp-wagmi-connector': specifier: 1.0.0 - version: 1.0.0(@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + version: 1.0.0(@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) '@tanstack/react-query': specifier: ^5.90.11 version: 5.90.11(react@19.2.6) @@ -2554,10 +2557,10 @@ importers: version: 19.2.6(react@19.2.6) viem: specifier: ^2.48.11 - version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) wagmi: specifier: ^2.14.11 - version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -3629,6 +3632,67 @@ importers: specifier: ^5.7.3 version: 5.9.3 + servers/upfront: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + '@x402/svm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/svm + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + servers/upto: dependencies: '@scure/base': @@ -12136,16 +12200,16 @@ snapshots: - ws - zod - '@base-org/account@2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)': + '@base-org/account@2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': dependencies: '@coinbase/cdp-sdk': 1.39.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 idb-keyval: 6.2.1 - ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) + ox: 0.6.9(typescript@5.9.3)(zod@4.1.13) preact: 10.24.2 - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) zustand: 5.0.3(@types/react@19.2.1)(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) transitivePeerDependencies: - '@types/react' @@ -12254,15 +12318,15 @@ snapshots: - utf-8-validate - zod - '@coinbase/wallet-sdk@4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.76)': + '@coinbase/wallet-sdk@4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 idb-keyval: 6.2.1 - ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) + ox: 0.6.9(typescript@5.9.3)(zod@4.1.13) preact: 10.24.2 - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) zustand: 5.0.3(@types/react@19.2.1)(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) transitivePeerDependencies: - '@types/react' @@ -12682,13 +12746,13 @@ snapshots: - typescript - utf-8-validate - '@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@farcaster/miniapp-core': 0.4.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) '@farcaster/quick-auth': 0.0.6(typescript@5.9.3) comlink: 4.4.2 eventemitter3: 5.0.1 - ox: 0.4.4(typescript@5.9.3)(zod@3.25.76) + ox: 0.4.4(typescript@5.9.3)(zod@4.1.13) transitivePeerDependencies: - bufferutil - encoding @@ -12696,11 +12760,11 @@ snapshots: - utf-8-validate - zod - '@farcaster/miniapp-wagmi-connector@1.0.0(@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@farcaster/miniapp-wagmi-connector@1.0.0(@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))': dependencies: - '@farcaster/miniapp-sdk': 0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@farcaster/miniapp-sdk': 0.2.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@farcaster/quick-auth@0.0.6(typescript@5.9.3)': dependencies: @@ -12747,11 +12811,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@gemini-wallet/core@0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@gemini-wallet/core@0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))': dependencies: '@metamask/rpc-errors': 7.0.2 eventemitter3: 5.0.1 - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -13792,11 +13856,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-common@1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-common@1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - bufferutil - typescript @@ -13837,13 +13901,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) valtio: 1.13.2(@types/react@19.2.1)(react@19.2.6) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -13906,12 +13970,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13) lit: 3.3.0 valtio: 1.13.2(@types/react@19.2.1)(react@19.2.6) transitivePeerDependencies: @@ -13981,12 +14045,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -14051,10 +14115,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -14122,16 +14186,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) valtio: 1.13.2(@types/react@19.2.1)(react@19.2.6) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -14223,21 +14287,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.1)(react@19.2.6))(zod@4.1.13) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.21.0 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.1)(react@19.2.6) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -14337,9 +14401,9 @@ snapshots: - utf-8-validate - zod - '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) events: 3.3.0 transitivePeerDependencies: - bufferutil @@ -14357,10 +14421,10 @@ snapshots: - utf-8-validate - zod - '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.23.1 - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - bufferutil - typescript @@ -18399,21 +18463,21 @@ snapshots: - utf-8-validate - zod - '@wagmi/connectors@6.2.0(54faf36800878bde8c5bec72b21c556d)': + '@wagmi/connectors@6.2.0(82714061ce2fc89c4f961c6dbad81f8f)': dependencies: - '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) - '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@base-org/account': 2.4.0(@types/react@19.1.10)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) + '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.1.10)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(7747cf1c632d0e340657f699559c49d2) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + porto: 0.2.35(5b6007fc9b4119a22ae7e95981ad7a36) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: - typescript: 5.9.3 + typescript: 5.9.2 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -18452,21 +18516,21 @@ snapshots: - ws - zod - '@wagmi/connectors@6.2.0(82714061ce2fc89c4f961c6dbad81f8f)': + '@wagmi/connectors@6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab)': dependencies: - '@base-org/account': 2.4.0(@types/react@19.1.10)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) - '@coinbase/wallet-sdk': 4.3.6(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) + '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.1.10)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(5b6007fc9b4119a22ae7e95981ad7a36) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + porto: 0.2.35(6efb213377217713e5f239ae3971a013) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) optionalDependencies: - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -18535,11 +18599,11 @@ snapshots: - react - use-sync-external-store - '@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))': dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) zustand: 5.0.0(@types/react@19.2.1)(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) optionalDependencies: '@tanstack/query-core': 5.90.11 @@ -18630,7 +18694,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -18644,7 +18708,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -18716,7 +18780,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -18730,7 +18794,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -18861,18 +18925,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@reown/appkit': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1 - '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/types': 2.21.1 - '@walletconnect/universal-provider': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19048,16 +19112,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@walletconnect/core': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19118,16 +19182,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@walletconnect/core': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19326,7 +19390,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 @@ -19335,9 +19399,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1 '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -19404,7 +19468,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 @@ -19413,9 +19477,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1 '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -19496,7 +19560,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -19514,7 +19578,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -19582,7 +19646,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -19600,7 +19664,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -19739,10 +19803,10 @@ snapshots: typescript: 5.9.2 zod: 3.25.76 - abitype@1.0.8(typescript@5.9.3)(zod@3.25.76): + abitype@1.0.8(typescript@5.9.3)(zod@4.1.13): optionalDependencies: typescript: 5.9.3 - zod: 3.25.76 + zod: 4.1.13 abitype@1.2.3(typescript@5.9.2)(zod@3.22.4): optionalDependencies: @@ -23062,6 +23126,20 @@ snapshots: transitivePeerDependencies: - zod + ox@0.4.4(typescript@5.9.3)(zod@4.1.13): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.13) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + ox@0.6.7(typescript@5.9.2)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.0 @@ -23076,14 +23154,14 @@ snapshots: transitivePeerDependencies: - zod - ox@0.6.7(typescript@5.9.3)(zod@3.25.76): + ox@0.6.7(typescript@5.9.3)(zod@4.1.13): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/curves': 1.9.7 '@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) + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.13) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -23104,14 +23182,14 @@ snapshots: transitivePeerDependencies: - zod - ox@0.6.9(typescript@5.9.3)(zod@3.25.76): + ox@0.6.9(typescript@5.9.3)(zod@4.1.13): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/curves': 1.9.7 '@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) + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.13) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -23358,14 +23436,14 @@ snapshots: - immer - use-sync-external-store - porto@0.2.35(7747cf1c632d0e340657f699559c49d2): + porto@0.2.35(6efb213377217713e5f239ae3971a013): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) hono: 4.10.7 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.9.3) ox: 0.9.17(typescript@5.9.3)(zod@4.1.13) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) zod: 4.1.13 zustand: 5.0.8(@types/react@19.2.1)(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) optionalDependencies: @@ -23373,7 +23451,7 @@ snapshots: react: 19.2.6 react-native: 0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10) typescript: 5.9.3 - wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) transitivePeerDependencies: - '@types/react' - immer @@ -24831,15 +24909,15 @@ snapshots: - utf-8-validate - zod - viem@2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13): dependencies: '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.0.8(typescript@5.9.3)(zod@4.1.13) isows: 1.0.6(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.6.7(typescript@5.9.3)(zod@3.25.76) + ox: 0.6.7(typescript@5.9.3)(zod@4.1.13) ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 @@ -25089,14 +25167,14 @@ snapshots: - ws - zod - wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): + wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): dependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) - '@wagmi/connectors': 6.2.0(54faf36800878bde8c5bec72b21c556d) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/connectors': 6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) react: 19.2.6 use-sync-external-store: 1.4.0(react@19.2.6) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: diff --git a/examples/typescript/servers/README.md b/examples/typescript/servers/README.md index 5a9dd39829..f76d239dbf 100644 --- a/examples/typescript/servers/README.md +++ b/examples/typescript/servers/README.md @@ -12,6 +12,7 @@ This directory contains TypeScript server examples demonstrating how to protect | [`self-facilitation/`](./self-facilitation/) | Express middleware with in-process SDK facilitator | | [`advanced/`](./advanced/) | Advanced patterns: hooks, dynamic pricing, custom tokens | | [`custom/`](./custom/) | Manual implementation without an x402 middleware package | +| [`upfront/`](./upfront/) | `exact` scheme with `upfront` payment flow (settle before handler) | | [`upto/`](./upto/) | `upto` scheme: authorize a ceiling, settle only actual usage | | [`batch-settlement/`](./batch-settlement/) | Off-chain vouchers claimed and settled in batches by a `ChannelManager` | | [`bazaar/`](./bazaar/) | Makes a paid API discoverable via the Bazaar extension | diff --git a/examples/typescript/servers/upfront/.env-local b/examples/typescript/servers/upfront/.env-local new file mode 100644 index 0000000000..aeea0693ab --- /dev/null +++ b/examples/typescript/servers/upfront/.env-local @@ -0,0 +1,3 @@ +EVM_ADDRESS= +SVM_ADDRESS= +FACILITATOR_URL=https://x402.org/facilitator diff --git a/examples/typescript/servers/upfront/.prettierignore b/examples/typescript/servers/upfront/.prettierignore new file mode 100644 index 0000000000..3049672b5c --- /dev/null +++ b/examples/typescript/servers/upfront/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md diff --git a/examples/typescript/servers/upfront/.prettierrc b/examples/typescript/servers/upfront/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/upfront/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/servers/upfront/README.md b/examples/typescript/servers/upfront/README.md new file mode 100644 index 0000000000..cfd10e214e --- /dev/null +++ b/examples/typescript/servers/upfront/README.md @@ -0,0 +1,98 @@ +# @x402/upfront Example Server + +Express.js server demonstrating the `upfront` payment flow on the `exact` scheme: the facilitator settles on-chain **before** the route handler runs. + +Use this when the resource needs on-chain finality before execution — for example, long-running handlers on Solana, where the default `authorization` flow requires the handler to finish before the signed transaction's blockhash expires (~60–90 seconds). + +```typescript +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + extra: { paymentFlow: "upfront" }, + }, + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); +``` + +## Trust model + +Under `upfront`, the payer commits funds before your handler runs. If the handler fails after settlement, the client has already paid; the middleware still echoes the settlement receipt in `PAYMENT-RESPONSE`. + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- Valid EVM and SVM addresses for receiving payments +- URL of a facilitator supporting the desired payment network, see [facilitator list](https://www.x402.org/ecosystem?category=facilitators) + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `FACILITATOR_URL` — facilitator endpoint URL +- `EVM_ADDRESS` — Ethereum address to receive payments +- `SVM_ADDRESS` — Solana address to receive payments + +2. Install and build all packages from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd servers/upfront +``` + +3. Run the server: + +```bash +pnpm dev +``` + +On a paid request you should see settlement logged before the handler: + +``` +[upfront] settled (before-handler) tx=0x... +[upfront] handler running (settlement already completed) +``` + +## Testing the Server + +You can test the server using one of the example clients: + +### Using the Fetch Client + +```bash +cd ../clients/fetch +# Ensure .env is setup +pnpm dev +``` + +### Using the Axios Client + +```bash +cd ../clients/axios +# Ensure .env is setup +pnpm dev +``` + +The 402 response includes `extra.paymentFlow: "upfront"`. + +## Example Endpoint + +The server includes a single example endpoint at `/weather` that requires a payment of 0.001 USDC on Base Sepolia or Solana Devnet to access. Settlement happens before the handler returns the weather report. diff --git a/examples/typescript/servers/upfront/eslint.config.js b/examples/typescript/servers/upfront/eslint.config.js new file mode 100644 index 0000000000..e2fde7b3b8 --- /dev/null +++ b/examples/typescript/servers/upfront/eslint.config.js @@ -0,0 +1,73 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + console: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/servers/upfront/index.ts b/examples/typescript/servers/upfront/index.ts new file mode 100644 index 0000000000..129977bd89 --- /dev/null +++ b/examples/typescript/servers/upfront/index.ts @@ -0,0 +1,72 @@ +import { config } from "dotenv"; +import express from "express"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { ExactSvmScheme } from "@x402/svm/exact/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +config(); + +const evmAddress = process.env.EVM_ADDRESS as `0x${string}`; +const svmAddress = process.env.SVM_ADDRESS; +if (!evmAddress || !svmAddress) { + console.error("Missing required environment variables"); + process.exit(1); +} + +const facilitatorUrl = process.env.FACILITATOR_URL; +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +const resourceServer = new x402ResourceServer(facilitatorClient) + .register("eip155:84532", new ExactEvmScheme()) + .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", new ExactSvmScheme()) + .onAfterSettle(async ({ phase, result }) => { + console.log(`[upfront] settled (${phase}) tx=${result.transaction}`); + }); + +const app = express(); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: [ + { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + extra: { paymentFlow: "upfront" }, + }, + { + scheme: "exact", + price: "$0.001", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: svmAddress, + extra: { paymentFlow: "upfront" }, + }, + ], + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); + +app.get("/weather", (_req, res) => { + console.log("[upfront] handler running (settlement already completed)"); + res.send({ + report: { + weather: "sunny", + temperature: 70, + }, + }); +}); + +app.listen(4021, () => { + console.log(`Server listening at http://localhost:4021`); +}); diff --git a/examples/typescript/servers/upfront/package.json b/examples/typescript/servers/upfront/package.json new file mode 100644 index 0000000000..10a239c955 --- /dev/null +++ b/examples/typescript/servers/upfront/package.json @@ -0,0 +1,35 @@ +{ + "name": "@x402/upfront-server-example", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx index.ts", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", + "lint": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts" + }, + "dependencies": { + "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "@x402/express": "workspace:*", + "@x402/svm": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.18.2" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@types/express": "^5.0.1", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsup": "^8.4.0", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/servers/upfront/tsconfig.json b/examples/typescript/servers/upfront/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/servers/upfront/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/go/.changes/unreleased/add-ethereum-avalanche-default-assets.yaml b/go/.changes/unreleased/add-ethereum-avalanche-default-assets.yaml deleted file mode 100644 index dfa0c0e6d0..0000000000 --- a/go/.changes/unreleased/add-ethereum-avalanche-default-assets.yaml +++ /dev/null @@ -1,2 +0,0 @@ -kind: added -body: Add Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) native USDC as default stablecoins diff --git a/go/.changes/unreleased/add-sei-default-stablecoin.yaml b/go/.changes/unreleased/add-sei-default-stablecoin.yaml deleted file mode 100644 index 4d5bd26f46..0000000000 --- a/go/.changes/unreleased/add-sei-default-stablecoin.yaml +++ /dev/null @@ -1,2 +0,0 @@ -kind: added -body: Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin diff --git a/go/.changes/unreleased/batch-settlement-local-truth.yaml b/go/.changes/unreleased/batch-settlement-local-truth.yaml deleted file mode 100644 index 808abcac82..0000000000 --- a/go/.changes/unreleased/batch-settlement-local-truth.yaml +++ /dev/null @@ -1,2 +0,0 @@ -kind: fixed -body: Stop persisting untrusted batch-settlement channelState from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped chargedAmount and any client-signed deposit, except when a present extra chargedCumulativeAmount does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. diff --git a/go/.changes/unreleased/changed-20260831-111823.yaml b/go/.changes/unreleased/changed-20260831-111823.yaml new file mode 100644 index 0000000000..c3de08e41c --- /dev/null +++ b/go/.changes/unreleased/changed-20260831-111823.yaml @@ -0,0 +1,3 @@ +kind: changed +body: Speed up EVM exact and upto facilitator verify by overlapping the asset-contract GetCode with signature checks and reusing VerifyUniversalSignature's bytecode result via ERC6492SignatureData.CodeDeployed instead of a second payer GetCode +time: 2026-08-31T11:18:23.734559-04:00 diff --git a/go/.changes/unreleased/fixed-20260727-140953.yaml b/go/.changes/unreleased/fixed-20260727-140953.yaml new file mode 100644 index 0000000000..72e2595c05 --- /dev/null +++ b/go/.changes/unreleased/fixed-20260727-140953.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: Prevented overflow when resolving large percentage settlement overrides. +time: 2026-07-27T14:09:53.922115+09:00 diff --git a/go/.changes/unreleased/fixed-20260827-095832.yaml b/go/.changes/unreleased/fixed-20260827-095832.yaml new file mode 100644 index 0000000000..2540ee08d2 --- /dev/null +++ b/go/.changes/unreleased/fixed-20260827-095832.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: fixed Golang SDK EIP-3009 client not using paymentRequirements.maxTimeoutSeconds for validBefore +time: 2026-08-27T09:58:32.096574+02:00 diff --git a/go/.changes/unreleased/fixed-20260830-150411.yaml b/go/.changes/unreleased/fixed-20260830-150411.yaml new file mode 100644 index 0000000000..4f06ab584b --- /dev/null +++ b/go/.changes/unreleased/fixed-20260830-150411.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: Reject undeclared or mismatched builder-code app attribution in Go resource servers and ignore payload app attribution outside v2 facilitator contexts. +time: 2026-08-30T15:04:11.5749+09:00 diff --git a/go/.changes/unreleased/fixed-20260831-143826.yaml b/go/.changes/unreleased/fixed-20260831-143826.yaml new file mode 100644 index 0000000000..a938ce655e --- /dev/null +++ b/go/.changes/unreleased/fixed-20260831-143826.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: Stop false-positive bazaar startup warnings for pre-enrichment HTTP extensions by injecting a synthetic method from the route pattern (or body/query inference) before JSON-schema validation, matching TypeScript middleware behavior +time: 2026-08-31T14:38:26.740113+02:00 diff --git a/go/.changes/unreleased/remove-network-config-bundled-helpers.yaml b/go/.changes/unreleased/remove-network-config-bundled-helpers.yaml deleted file mode 100644 index 6bff448125..0000000000 --- a/go/.changes/unreleased/remove-network-config-bundled-helpers.yaml +++ /dev/null @@ -1,2 +0,0 @@ -kind: changed -body: SVM `GetNetworkConfig` and `NetworkConfig` now hold transport endpoints only (RPC/WS). Default assets stay in `default_assets.go`. EVM bundled network config remains removed; use `GetDefaultAsset` and `GetEvmChainId`. diff --git a/go/.changes/v2.24.0.md b/go/.changes/v2.24.0.md new file mode 100644 index 0000000000..6f24b28ff0 --- /dev/null +++ b/go/.changes/v2.24.0.md @@ -0,0 +1,14 @@ +## v2.24.0 - 2026-08-27 +### Added +- Add Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) native USDC as default stablecoins ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +- Add Go auth-capture client with ERC-3009 and Permit2 collect payload signing ([#3283](https://github.com/x402-foundation/x402/pull/3283)) - Thanks [@phdargen](https://github.com/phdargen)! +- Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin ([#3227](https://github.com/x402-foundation/x402/pull/3227)) - Thanks [@alexander-sei](https://github.com/alexander-sei)! +- Add simulation-based smart wallet verification to the SVM exact facilitator, behind Config.EnableSmartWalletVerification ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! +- Declare `upfront` payment flow support on EVM and SVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +### Changed +- Verify SVM exact payments with local Ed25519 signature checks instead of a fee-payer signing round trip; FacilitatorSvmSigner.SimulateTransaction now runs with sigVerify off, and NewExactSvmScheme takes an optional Config ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! +- Route SVM upto facilitator RPC through the signer instead of scheme Config.RPC/RPCURL — claim settlement simulates before send, deposit composite sim uses a placeholder blockhash with replaceRecentBlockhash, and claim overlaps channel read with blockhash prefetch ([#3274](https://github.com/x402-foundation/x402/pull/3274)) - Thanks [@phdargen](https://github.com/phdargen)! +- SVM `GetNetworkConfig` and `NetworkConfig` now hold transport endpoints only (RPC/WS). Default assets stay in `default_assets.go`. EVM bundled network config remains removed; use `GetDefaultAsset` and `GetEvmChainId`. ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +### Fixed +- Stop persisting untrusted batch-settlement channelState from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped chargedAmount and any client-signed deposit, except when a present extra chargedCumulativeAmount does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. ([#3251](https://github.com/x402-foundation/x402/pull/3251)) - Thanks [@phdargen](https://github.com/phdargen)! +- Align SVM exact Path 1 with TypeScript — accept 3–7 instructions and require exact transfer amount equality ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! \ No newline at end of file diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index ee1989eeac..aa59ee378e 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,3 +1,18 @@ +## v2.24.0 - 2026-08-27 +### Added +- Add Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) native USDC as default stablecoins ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +- Add Go auth-capture client with ERC-3009 and Permit2 collect payload signing ([#3283](https://github.com/x402-foundation/x402/pull/3283)) - Thanks [@phdargen](https://github.com/phdargen)! +- Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin ([#3227](https://github.com/x402-foundation/x402/pull/3227)) - Thanks [@alexander-sei](https://github.com/alexander-sei)! +- Add simulation-based smart wallet verification to the SVM exact facilitator, behind Config.EnableSmartWalletVerification ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! +- Declare `upfront` payment flow support on EVM and SVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +### Changed +- Verify SVM exact payments with local Ed25519 signature checks instead of a fee-payer signing round trip; FacilitatorSvmSigner.SimulateTransaction now runs with sigVerify off, and NewExactSvmScheme takes an optional Config ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! +- Route SVM upto facilitator RPC through the signer instead of scheme Config.RPC/RPCURL — claim settlement simulates before send, deposit composite sim uses a placeholder blockhash with replaceRecentBlockhash, and claim overlaps channel read with blockhash prefetch ([#3274](https://github.com/x402-foundation/x402/pull/3274)) - Thanks [@phdargen](https://github.com/phdargen)! +- SVM `GetNetworkConfig` and `NetworkConfig` now hold transport endpoints only (RPC/WS). Default assets stay in `default_assets.go`. EVM bundled network config remains removed; use `GetDefaultAsset` and `GetEvmChainId`. ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +### Fixed +- Stop persisting untrusted batch-settlement channelState from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped chargedAmount and any client-signed deposit, except when a present extra chargedCumulativeAmount does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. ([#3251](https://github.com/x402-foundation/x402/pull/3251)) - Thanks [@phdargen](https://github.com/phdargen)! +- Align SVM exact Path 1 with TypeScript — accept 3–7 instructions and require exact transfer amount equality ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! + ## v2.23.0 - 2026-08-18 ### Added - Add the SVM `upto` scheme (client, server, facilitator) for usage-based billing on Solana, backed by hand-written payment-channels program bindings in mechanisms/svm/paymentchannels. The client escrows a maximum in an onchain channel, the server signs a voucher for the metered amount, and the facilitator sponsors fees/rent, co-signs and broadcasts the open, settles the claim, and reclaims channel rent through RentCleanupManager over a pluggable ChannelStorage. Cleanup runs on a short interval from storage while the optional getProgramAccounts recovery sweep (Discover) runs on its own longer interval, and both prefer an injected *rpc.Client over the configured RPC URL so operators can route sends through their own instrumented transport ([#3141](https://github.com/x402-foundation/x402/pull/3141)) - Thanks [@CarsonRoscoe](https://github.com/CarsonRoscoe) and [@cursoragent](https://github.com/cursoragent), [@claude](https://github.com/claude)! diff --git a/go/constants.go b/go/constants.go index 247f48a8f0..fb7b7d23f5 100644 --- a/go/constants.go +++ b/go/constants.go @@ -3,7 +3,7 @@ package x402 // Version constants const ( // Version is the SDK version - Version = "2.23.0" + Version = "2.24.0" // ProtocolVersion is the current x402 protocol version ProtocolVersion = 2 diff --git a/go/errors.go b/go/errors.go index 014d2e5cf1..9729394b6e 100644 --- a/go/errors.go +++ b/go/errors.go @@ -44,6 +44,16 @@ const ( ErrFailedToMarshalRequirements = "failed_to_marshal_requirements" ) +// ErrSettlementPending is the generic (scheme/network-agnostic) settle error +// reason meaning a transaction broadcast successfully but its receipt/ +// confirmation wait failed — non-terminal, and always carries the broadcast +// transaction hash so a caller can reconcile onchain. Mirrors +// evm.ErrSettlementPending (go/mechanisms/evm/errors.go); duplicated here as a +// plain string constant so core does not depend on the mechanisms package. +// x402ResourceServer.SettlePaymentWithExtensions uses this to drive its +// single automatic settle retry (see settleWithPendingRetry in server.go). +const ErrSettlementPending = "settlement_pending" + // NewPaymentError creates a new payment error func NewPaymentError(code, message string, details map[string]interface{}) *PaymentError { return &PaymentError{ diff --git a/go/extensions/bazaar/startup_validation.go b/go/extensions/bazaar/startup_validation.go new file mode 100644 index 0000000000..0f92900d5f --- /dev/null +++ b/go/extensions/bazaar/startup_validation.go @@ -0,0 +1,118 @@ +package bazaar + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/x402-foundation/x402/go/v2/extensions/types" + x402http "github.com/x402-foundation/x402/go/v2/http" +) + +var httpVerbRE = regexp.MustCompile(`(?i)^(GET|POST|PUT|PATCH|DELETE|HEAD)\b`) + +// withSyntheticMethod injects a synthetic method for startup schema validation only. +// +// Pre-enrichment HTTP extensions intentionally omit method; it is added at request +// time by BazaarResourceServerExtension. Without a synthetic value, jsonschema reports +// a false positive for the required method field. +// +// Priority: (1) route pattern verb (e.g. "GET /api"), (2) body vs query inference. +// Returns the same extension unchanged if method is already present. +func withSyntheticMethod(ext types.DiscoveryExtension, pattern string) types.DiscoveryExtension { + switch input := ext.Info.Input.(type) { + case types.QueryInput: + if input.Method != "" { + return ext + } + input.Method = types.QueryParamMethods(inferSyntheticMethod(pattern, input)) + ext.Info.Input = input + case types.BodyInput: + if input.Method != "" { + return ext + } + input.Method = types.BodyMethods(inferSyntheticMethod(pattern, input)) + ext.Info.Input = input + } + return ext +} + +func inferSyntheticMethod(pattern string, input interface{}) string { + if matches := httpVerbRE.FindStringSubmatch(pattern); len(matches) > 1 { + return strings.ToUpper(matches[1]) + } + + switch in := input.(type) { + case types.BodyInput: + if in.Body != nil || in.BodyType != "" { + return "POST" + } + case types.QueryInput: + // Query-only extensions default to GET below. + } + + return "GET" +} + +// ValidateBazaarRouteExtensions validates bazaar extensions on all routes using +// JSON-schema validation. Emits warnings for invalid extensions but does not block startup. +func ValidateBazaarRouteExtensions(routes x402http.RoutesConfig) { + for pattern, config := range routes { + validateSingleBazaarExtension(pattern, config.Extensions) + } +} + +// ValidateBazaarRouteExtensionsFromServer validates bazaar extensions from pre-compiled routes. +func ValidateBazaarRouteExtensionsFromServer(server *x402http.HTTPServer) { + for _, route := range server.GetCompiledRoutes() { + pattern := route.Verb + " " + route.Regex.String() + validateSingleBazaarExtension(pattern, route.Config.Extensions) + } +} + +func validateSingleBazaarExtension(pattern string, extensions map[string]interface{}) { + extVal, ok := extensions[types.BAZAAR.Key()] + if !ok || extVal == nil { + return + } + + switch v := extVal.(type) { + case map[string]interface{}: + if v["info"] == nil || v["schema"] == nil { + fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ + "(expected an object with \"info\" and \"schema\" fields)\n", pattern) + return + } + case types.DiscoveryExtension: + if v.Schema == nil { + fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ + "(expected an object with \"info\" and \"schema\" fields)\n", pattern) + return + } + default: + fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ + "(expected an object with \"info\" and \"schema\" fields)\n", pattern) + return + } + + extJSON, err := json.Marshal(extVal) + if err != nil { + return + } + var ext types.DiscoveryExtension + if err := json.Unmarshal(extJSON, &ext); err != nil { + return + } + specResult := ValidateDiscoveryExtensionSpec(ext) + if !specResult.Valid { + fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", + pattern, strings.Join(specResult.Errors, ", ")) + return + } + result := ValidateDiscoveryExtension(withSyntheticMethod(ext, pattern)) + if !result.Valid { + fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", + pattern, strings.Join(result.Errors, ", ")) + } +} diff --git a/go/extensions/bazaar/startup_validation_test.go b/go/extensions/bazaar/startup_validation_test.go new file mode 100644 index 0000000000..13da792f77 --- /dev/null +++ b/go/extensions/bazaar/startup_validation_test.go @@ -0,0 +1,320 @@ +package bazaar + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/x402-foundation/x402/go/v2/extensions/types" + x402http "github.com/x402-foundation/x402/go/v2/http" +) + +func captureStartupValidationStdout(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + f() + w.Close() + os.Stdout = old + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestValidateBazaarRouteExtensions_NoBazaar(t *testing.T) { + routes := x402http.RoutesConfig{ + "GET /api": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.NotContains(t, output, "Warning") + assert.NotContains(t, output, "bazaar") +} + +func TestValidateBazaarRouteExtensions_ValidExtension(t *testing.T) { + routes := x402http.RoutesConfig{ + "GET /api": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + "bazaar": map[string]interface{}{ + "info": map[string]interface{}{ + "input": map[string]interface{}{ + "type": "http", + "method": "GET", + }, + }, + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{"type": "object"}, + }, + "required": []interface{}{"input"}, + }, + }, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.NotContains(t, output, "Warning") + assert.NotContains(t, output, "invalid") +} + +func TestValidateBazaarRouteExtensions_InvalidExtension(t *testing.T) { + routes := x402http.RoutesConfig{ + "GET /api": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + "bazaar": map[string]interface{}{ + "info": map[string]interface{}{ + "input": map[string]interface{}{ + "type": "http", + "method": "GET", + }, + }, + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{"type": "object"}, + "jobs": map[string]interface{}{"type": "array"}, + "count": map[string]interface{}{"type": "integer"}, + }, + "required": []interface{}{"input", "jobs", "count"}, + }, + }, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.Contains(t, output, "Warning") + assert.Contains(t, output, "bazaar") +} + +func TestValidateBazaarRouteExtensions_MalformedExtension(t *testing.T) { + routes := x402http.RoutesConfig{ + "GET /api": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + "bazaar": "not-an-object", + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.Contains(t, output, "Warning") + assert.Contains(t, output, "malformed") +} + +func TestValidateBazaarRouteExtensions_DeclareDiscoveryExtensionGET(t *testing.T) { + extension, err := DeclareDiscoveryExtension( + MethodGET, + map[string]interface{}{"city": "San Francisco"}, + types.JSONSchema{ + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + "required": []string{"city"}, + }, + "", + &types.OutputConfig{ + Example: map[string]interface{}{ + "weather": "sunny", + "temperature": 70, + }, + Schema: types.JSONSchema{ + "properties": map[string]interface{}{ + "weather": map[string]interface{}{"type": "string"}, + "temperature": map[string]interface{}{"type": "number"}, + }, + "required": []string{"weather", "temperature"}, + }, + }, + ) + require.NoError(t, err) + + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + BAZAAR.Key(): extension, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.NotContains(t, output, "Warning") +} + +func TestValidateBazaarRouteExtensions_PreEnrichmentWithoutMethod(t *testing.T) { + ext := types.DiscoveryExtension{ + Info: types.DiscoveryInfo{ + Input: types.QueryInput{ + Type: "http", + QueryParams: map[string]interface{}{"city": "San Francisco"}, + }, + }, + Schema: types.JSONSchema{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "type": map[string]interface{}{"type": "string", "const": "http"}, + "method": map[string]interface{}{"type": "string", "enum": []string{"GET", "HEAD", "DELETE"}}, + "queryParams": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + "required": []string{"city"}, + }, + }, + "required": []string{"type", "method"}, + "additionalProperties": false, + }, + }, + "required": []string{"input"}, + }, + } + + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + BAZAAR.Key(): ext, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.NotContains(t, output, "Warning") +} + +func TestValidateBazaarRouteExtensions_PostBodyWithoutRouteVerb(t *testing.T) { + extension, err := DeclareDiscoveryExtension( + MethodPOST, + map[string]interface{}{"name": "John"}, + types.JSONSchema{ + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + }, + "required": []string{"name"}, + }, + types.BodyTypeJSON, + &types.OutputConfig{ + Example: map[string]interface{}{"success": true}, + }, + ) + require.NoError(t, err) + + extMap := map[string]interface{}{} + extJSON, err := json.Marshal(extension) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(extJSON, &extMap)) + info := extMap["info"].(map[string]interface{}) + input := info["input"].(map[string]interface{}) + delete(input, "method") + + routes := x402http.RoutesConfig{ + "*": { + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:8453"}, + }, + Extensions: map[string]interface{}{ + BAZAAR.Key(): extMap, + }, + }, + } + + output := captureStartupValidationStdout(func() { + ValidateBazaarRouteExtensions(routes) + }) + + assert.NotContains(t, output, "Warning") +} + +func TestWithSyntheticMethod_LeavesExistingMethodUnchanged(t *testing.T) { + ext := types.DiscoveryExtension{ + Info: types.DiscoveryInfo{ + Input: types.QueryInput{ + Type: "http", + Method: types.MethodDELETE, + }, + }, + } + + result := withSyntheticMethod(ext, "GET /api") + input, ok := result.Info.Input.(types.QueryInput) + require.True(t, ok) + assert.Equal(t, types.MethodDELETE, input.Method) +} + +func TestWithSyntheticMethod_InjectsFromRoutePattern(t *testing.T) { + ext := types.DiscoveryExtension{ + Info: types.DiscoveryInfo{ + Input: types.QueryInput{ + Type: "http", + QueryParams: map[string]interface{}{"city": "sf"}, + }, + }, + } + + result := withSyntheticMethod(ext, "GET /weather") + input, ok := result.Info.Input.(types.QueryInput) + require.True(t, ok) + assert.Equal(t, types.MethodGET, input.Method) +} + +func TestWithSyntheticMethod_InfersPostFromBody(t *testing.T) { + ext := types.DiscoveryExtension{ + Info: types.DiscoveryInfo{ + Input: types.BodyInput{ + Type: "http", + BodyType: types.BodyTypeJSON, + Body: map[string]interface{}{"name": "John"}, + }, + }, + } + + result := withSyntheticMethod(ext, "*") + input, ok := result.Info.Input.(types.BodyInput) + require.True(t, ok) + assert.Equal(t, types.MethodPOST, input.Method) +} diff --git a/go/extensions/buildercode/facilitator.go b/go/extensions/buildercode/facilitator.go index 0980cd9f86..fa0bee28c7 100644 --- a/go/extensions/buildercode/facilitator.go +++ b/go/extensions/buildercode/facilitator.go @@ -8,9 +8,10 @@ import ( // BuilderCodeFacilitatorExtension manages builder-code attribution at settlement // time. When BuilderCode is set, it is encoded as the wallet code (`w`); the app -// code (`a`) and service code (`s`) are read from the client payment payload -// extensions. When ServiceCode is set, it is appended to `s` within the -// facilitator's own MAX_FACILITATOR_SERVICE_CODES reservation. It implements +// code (`a`) is read from v2 client payment payload extensions, while service +// codes (`s`) are read from either version. When ServiceCode is set, it is +// appended to `s` within the facilitator's own +// MAX_FACILITATOR_SERVICE_CODES reservation. It implements // evm.BuilderCodeFacilitatorExtension so the base evm settle paths can resolve // and append the ERC-8021 calldata suffix. type BuilderCodeFacilitatorExtension struct { @@ -33,12 +34,13 @@ func (e *BuilderCodeFacilitatorExtension) Key() string { } // BuildDataSuffix builds the ERC-8021 Schema 2 calldata suffix for a settlement. -// `a` and `s` come from the client payment payload extensions; `w` is the -// facilitator's own code when configured. The facilitator's own `s` entry -// (ServiceCode) is appended after the echoed client/server codes, within its -// own MAX_FACILITATOR_SERVICE_CODES reservation. Returns an error when -// ServiceCode is set but is not a valid builder code. Returns nil when no -// attribution is present. +// For v2 payloads, `a` comes from the client payment payload extensions. It is +// omitted for v1 payloads, where the resource-server echo gate does not run. +// `s` is read for either version and `w` is the facilitator's own code when +// configured. The facilitator's own `s` entry (ServiceCode) is appended after +// the echoed client/server codes, within its own MAX_FACILITATOR_SERVICE_CODES +// reservation. Returns an error when ServiceCode is set but is not a valid +// builder code. Returns nil when no attribution is present. func (e *BuilderCodeFacilitatorExtension) BuildDataSuffix(ctx evm.DataSuffixContext) ([]byte, error) { clientExt := extractClientExtension(ctx.Payload.Extensions) @@ -46,8 +48,10 @@ func (e *BuilderCodeFacilitatorExtension) BuildDataSuffix(ctx evm.DataSuffixCont if validateCode(e.BuilderCode) { data.W = e.BuilderCode } - if a, ok := clientExt["a"].(string); ok && validateCode(a) { - data.A = a + if ctx.Payload.X402Version == 2 { + if a, ok := clientExt["a"].(string); ok && validateCode(a) { + data.A = a + } } data.S = resolveServiceCodes(clientExt["s"]) if e.ServiceCode != "" { diff --git a/go/extensions/buildercode/facilitator_test.go b/go/extensions/buildercode/facilitator_test.go index 4cd83ddeca..a0af73f638 100644 --- a/go/extensions/buildercode/facilitator_test.go +++ b/go/extensions/buildercode/facilitator_test.go @@ -188,6 +188,25 @@ func TestBuildDataSuffixReadsAppCode(t *testing.T) { } } +func TestBuildDataSuffixDropsAppCodeForV1Payload(t *testing.T) { + ctx := suffixContext(map[string]interface{}{"a": appCode, "s": serviceCode}) + ctx.Payload.X402Version = 1 + + ext := &BuilderCodeFacilitatorExtension{BuilderCode: walletCode, ServiceCode: "bc_fac"} + suffix, err := ext.BuildDataSuffix(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + parsed, ok := ParseBuilderCodeSuffixFromCalldata("0xdeadbeef" + hex.EncodeToString(suffix)) + if !ok { + t.Fatal("expected a valid suffix") + } + want := []string{serviceCode, "bc_fac"} + if parsed.W != walletCode || parsed.A != "" || !reflect.DeepEqual(parsed.S, want) { + t.Fatalf("expected wallet and client/facilitator services without app code, got %+v", parsed) + } +} + func TestBuildDataSuffixIgnoresInvalidWalletCode(t *testing.T) { ext := &BuilderCodeFacilitatorExtension{BuilderCode: "X"} suffix, err := ext.BuildDataSuffix(suffixContext(map[string]interface{}{"a": appCode})) diff --git a/go/http/echo/middleware.go b/go/http/echo/middleware.go index 932c530f03..2b2e2e82ca 100644 --- a/go/http/echo/middleware.go +++ b/go/http/echo/middleware.go @@ -6,14 +6,12 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "sync" "time" "github.com/labstack/echo/v4" x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/extensions/bazaar" - extypes "github.com/x402-foundation/x402/go/v2/extensions/types" x402http "github.com/x402-foundation/x402/go/v2/http" ) @@ -189,7 +187,7 @@ func PaymentMiddleware(routes x402http.RoutesConfig, server *x402.X402ResourceSe httpServer := x402http.Wrappedx402HTTPResourceServer(routes, server) httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) // Initialize if requested - queries facilitator /supported to populate facilitatorClients map if config.SyncFacilitatorOnStart { @@ -229,7 +227,7 @@ func PaymentMiddlewareFromHTTPServer(httpServer *x402http.HTTPServer, opts ...Mi } httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensionsFromServer(httpServer) + bazaar.ValidateBazaarRouteExtensionsFromServer(httpServer) // Initialize if requested - queries facilitator /supported to populate facilitatorClients map if config.SyncFacilitatorOnStart { @@ -268,7 +266,7 @@ func PaymentMiddlewareFromConfig(routes x402http.RoutesConfig, opts ...Middlewar httpServer := x402http.Newx402HTTPResourceServer(config.Routes, serverOpts...) httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensions(config.Routes) + bazaar.ValidateBazaarRouteExtensions(config.Routes) // Register schemes for _, scheme := range config.Schemes { @@ -574,51 +572,3 @@ func (w *responseCapture) WriteString(s string) (int, error) { // Flush is a no-op to prevent premature flushing to the wire before settlement. func (w *responseCapture) Flush() {} - -// validateBazaarExtensions validates all bazaar extensions declared on routes using -// the bazaar package's JSON-schema validator. Emits warnings but does not block startup. -func validateBazaarExtensions(routes x402http.RoutesConfig) { - for pattern, config := range routes { - validateSingleBazaarExtension(pattern, config.Extensions) - } -} - -// validateBazaarExtensionsFromServer validates bazaar extensions from pre-compiled routes. -func validateBazaarExtensionsFromServer(server *x402http.HTTPServer) { - for _, route := range server.GetCompiledRoutes() { - pattern := route.Verb + " " + route.Regex.String() - validateSingleBazaarExtension(pattern, route.Config.Extensions) - } -} - -func validateSingleBazaarExtension(pattern string, extensions map[string]interface{}) { - extVal, ok := extensions[extypes.BAZAAR.Key()] - if !ok || extVal == nil { - return - } - extMap, isMap := extVal.(map[string]interface{}) - if !isMap || extMap["info"] == nil || extMap["schema"] == nil { - fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ - "(expected an object with \"info\" and \"schema\" fields)\n", pattern) - return - } - extJSON, err := json.Marshal(extVal) - if err != nil { - return - } - var ext extypes.DiscoveryExtension - if err := json.Unmarshal(extJSON, &ext); err != nil { - return - } - specResult := bazaar.ValidateDiscoveryExtensionSpec(ext) - if !specResult.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(specResult.Errors, ", ")) - return - } - result := bazaar.ValidateDiscoveryExtension(ext) - if !result.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(result.Errors, ", ")) - } -} diff --git a/go/http/echo/middleware_test.go b/go/http/echo/middleware_test.go index e26905bda2..62937c5167 100644 --- a/go/http/echo/middleware_test.go +++ b/go/http/echo/middleware_test.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/echo/v4" x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/extensions/bazaar" x402http "github.com/x402-foundation/x402/go/v2/http" "github.com/x402-foundation/x402/go/v2/types" ) @@ -1383,7 +1384,7 @@ func TestValidateBazaarExtensions_NoBazaar(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "bazaar") { @@ -1418,7 +1419,7 @@ func TestValidateBazaarExtensions_ValidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "invalid") { @@ -1455,7 +1456,7 @@ func TestValidateBazaarExtensions_InvalidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { @@ -1479,7 +1480,7 @@ func TestValidateBazaarExtensions_MalformedExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { diff --git a/go/http/gin/middleware.go b/go/http/gin/middleware.go index adb4cc37bc..79ce1fd681 100644 --- a/go/http/gin/middleware.go +++ b/go/http/gin/middleware.go @@ -6,14 +6,12 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "sync" "time" "github.com/gin-gonic/gin" x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/extensions/bazaar" - extypes "github.com/x402-foundation/x402/go/v2/extensions/types" x402http "github.com/x402-foundation/x402/go/v2/http" ) @@ -198,7 +196,7 @@ func PaymentMiddleware(routes x402http.RoutesConfig, server *x402.X402ResourceSe } } - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) // Create middleware handler using shared logic return createMiddlewareHandler(httpServer, config) @@ -239,7 +237,7 @@ func PaymentMiddlewareFromHTTPServer(httpServer *x402http.HTTPServer, opts ...Mi } } - validateBazaarExtensionsFromServer(httpServer) + bazaar.ValidateBazaarRouteExtensionsFromServer(httpServer) // Create middleware handler using shared logic return createMiddlewareHandler(httpServer, config) @@ -284,60 +282,12 @@ func PaymentMiddlewareFromConfig(routes x402http.RoutesConfig, opts ...Middlewar } } - validateBazaarExtensions(config.Routes) + bazaar.ValidateBazaarRouteExtensions(config.Routes) // Create middleware handler return createMiddlewareHandler(httpServer, config) } -// validateBazaarExtensions validates all bazaar extensions declared on routes using -// the bazaar package's JSON-schema validator. Emits warnings but does not block startup. -func validateBazaarExtensions(routes x402http.RoutesConfig) { - for pattern, config := range routes { - validateSingleBazaarExtension(pattern, config.Extensions) - } -} - -// validateBazaarExtensionsFromServer validates bazaar extensions from pre-compiled routes. -func validateBazaarExtensionsFromServer(server *x402http.HTTPServer) { - for _, route := range server.GetCompiledRoutes() { - pattern := route.Verb + " " + route.Regex.String() - validateSingleBazaarExtension(pattern, route.Config.Extensions) - } -} - -func validateSingleBazaarExtension(pattern string, extensions map[string]interface{}) { - extVal, ok := extensions[extypes.BAZAAR.Key()] - if !ok || extVal == nil { - return - } - extMap, isMap := extVal.(map[string]interface{}) - if !isMap || extMap["info"] == nil || extMap["schema"] == nil { - fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ - "(expected an object with \"info\" and \"schema\" fields)\n", pattern) - return - } - extJSON, err := json.Marshal(extVal) - if err != nil { - return - } - var ext extypes.DiscoveryExtension - if err := json.Unmarshal(extJSON, &ext); err != nil { - return - } - specResult := bazaar.ValidateDiscoveryExtensionSpec(ext) - if !specResult.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(specResult.Errors, ", ")) - return - } - result := bazaar.ValidateDiscoveryExtension(ext) - if !result.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(result.Errors, ", ")) - } -} - // createMiddlewareHandler creates the actual Gin handler function. func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConfig) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/go/http/gin/middleware_test.go b/go/http/gin/middleware_test.go index 84e5cd81ab..e22d9103e8 100644 --- a/go/http/gin/middleware_test.go +++ b/go/http/gin/middleware_test.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/extensions/bazaar" x402http "github.com/x402-foundation/x402/go/v2/http" "github.com/x402-foundation/x402/go/v2/types" ) @@ -1638,7 +1639,7 @@ func TestValidateBazaarExtensions_NoBazaar(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "bazaar") { @@ -1673,7 +1674,7 @@ func TestValidateBazaarExtensions_ValidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "invalid") { @@ -1710,7 +1711,7 @@ func TestValidateBazaarExtensions_InvalidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { @@ -1734,7 +1735,7 @@ func TestValidateBazaarExtensions_MalformedExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { diff --git a/go/http/nethttp/middleware.go b/go/http/nethttp/middleware.go index dc527fab4a..b87110822a 100644 --- a/go/http/nethttp/middleware.go +++ b/go/http/nethttp/middleware.go @@ -6,13 +6,11 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "sync" "time" x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/extensions/bazaar" - extypes "github.com/x402-foundation/x402/go/v2/extensions/types" x402http "github.com/x402-foundation/x402/go/v2/http" ) @@ -133,7 +131,7 @@ func PaymentMiddleware(routes x402http.RoutesConfig, server *x402.X402ResourceSe httpServer := x402http.Wrappedx402HTTPResourceServer(routes, server) httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) if config.SyncFacilitatorOnStart { ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) @@ -169,7 +167,7 @@ func PaymentMiddlewareFromConfig(routes x402http.RoutesConfig, opts ...Middlewar httpServer := x402http.Newx402HTTPResourceServer(config.Routes, serverOpts...) httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensions(config.Routes) + bazaar.ValidateBazaarRouteExtensions(config.Routes) for _, scheme := range config.Schemes { httpServer.Register(scheme.Network, scheme.Server) @@ -210,7 +208,7 @@ func PaymentMiddlewareFromHTTPServer(httpServer *x402http.HTTPServer, opts ...Mi } httpServer.RegisterExtension(bazaar.BazaarResourceServerExtension) - validateBazaarExtensionsFromServer(httpServer) + bazaar.ValidateBazaarRouteExtensionsFromServer(httpServer) if config.SyncFacilitatorOnStart { ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) @@ -467,51 +465,3 @@ func (w *responseCapture) Write(data []byte) (int, error) { } return w.body.Write(data) } - -// validateBazaarExtensions validates all bazaar extensions declared on routes using -// the bazaar package's JSON-schema validator. Emits warnings but does not block startup. -func validateBazaarExtensions(routes x402http.RoutesConfig) { - for pattern, config := range routes { - validateSingleBazaarExtension(pattern, config.Extensions) - } -} - -// validateBazaarExtensionsFromServer validates bazaar extensions from pre-compiled routes. -func validateBazaarExtensionsFromServer(server *x402http.HTTPServer) { - for _, route := range server.GetCompiledRoutes() { - pattern := route.Verb + " " + route.Regex.String() - validateSingleBazaarExtension(pattern, route.Config.Extensions) - } -} - -func validateSingleBazaarExtension(pattern string, extensions map[string]interface{}) { - extVal, ok := extensions[extypes.BAZAAR.Key()] - if !ok || extVal == nil { - return - } - extMap, isMap := extVal.(map[string]interface{}) - if !isMap || extMap["info"] == nil || extMap["schema"] == nil { - fmt.Printf("x402 Warning: Route %q declares a bazaar extension but it is malformed "+ - "(expected an object with \"info\" and \"schema\" fields)\n", pattern) - return - } - extJSON, err := json.Marshal(extVal) - if err != nil { - return - } - var ext extypes.DiscoveryExtension - if err := json.Unmarshal(extJSON, &ext); err != nil { - return - } - specResult := bazaar.ValidateDiscoveryExtensionSpec(ext) - if !specResult.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(specResult.Errors, ", ")) - return - } - result := bazaar.ValidateDiscoveryExtension(ext) - if !result.Valid { - fmt.Printf("x402 Warning: Route %q has invalid bazaar extension: %s\n", - pattern, strings.Join(result.Errors, ", ")) - } -} diff --git a/go/http/nethttp/middleware_test.go b/go/http/nethttp/middleware_test.go index a571900d47..efae68211e 100644 --- a/go/http/nethttp/middleware_test.go +++ b/go/http/nethttp/middleware_test.go @@ -14,6 +14,7 @@ import ( "time" x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/extensions/bazaar" x402http "github.com/x402-foundation/x402/go/v2/http" "github.com/x402-foundation/x402/go/v2/types" ) @@ -1436,7 +1437,7 @@ func TestValidateBazaarExtensions_NoBazaar(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "bazaar") { @@ -1471,7 +1472,7 @@ func TestValidateBazaarExtensions_ValidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if strings.Contains(output, "Warning") || strings.Contains(output, "invalid") { @@ -1508,7 +1509,7 @@ func TestValidateBazaarExtensions_InvalidExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { @@ -1532,7 +1533,7 @@ func TestValidateBazaarExtensions_MalformedExtension(t *testing.T) { } output := captureStdout(func() { - validateBazaarExtensions(routes) + bazaar.ValidateBazaarRouteExtensions(routes) }) if !strings.Contains(output, "Warning") { diff --git a/go/mechanisms/evm/auth-capture/README.md b/go/mechanisms/evm/auth-capture/README.md new file mode 100644 index 0000000000..60b7c73306 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/README.md @@ -0,0 +1,36 @@ +# Auth-Capture EVM Scheme (`go/mechanisms/evm/auth-capture`) + +The **auth-capture** scheme adds refundable payments to x402, built on Base's audited [Commerce Payments Protocol](https://github.com/base/commerce-payments). The client signs a single collect payload (ERC-3009 by default, or Permit2) whose nonce is the payer-agnostic PaymentInfo hash. + +See the [auth-capture EVM specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth_capture_evm.md) for protocol details. + +## Import Path + +| Role | Import | +| ------ | --------------------------------------------------------------------------- | +| Client | `github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client` | + +## Client Usage + +Register `AuthCaptureEvmScheme` with an `x402Client`. The client signs the payer-agnostic PaymentInfo hash and emits an ERC-3009 (default) or Permit2 payload. + +When `extra.receiverAuthorizer` or `extra.policy` is non-zero, salt binding is on: the client emits a random `saltNonce` and a keccak `salt` committing to those addresses. Otherwise the wire shape is unbound (`salt` is random 32 bytes, no `saltNonce`). + +The client resolves the commerce-payments deployment from optional `extra.authCaptureEscrow` (v1.1 default when omitted). That selects the escrow bound into the signature nonce and the collector used for `authorization.to` / `permit2Authorization.spender`. + +```go +import ( + x402 "github.com/x402-foundation/x402/go/v2" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +signer, _ := evmsigners.NewClientSignerFromPrivateKey(os.Getenv("EVM_PRIVATE_KEY")) + +client := x402.Newx402Client() +client.Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(signer)) +``` + +`ClientEvmSigner` only needs `Address()` and `SignTypedData`; no RPC is required for payload construction. + +The client participates in the collect (`authorize` / `charge`) step only. Capture, void, and refund lifecycle payloads are server/facilitator responsibilities. diff --git a/go/mechanisms/evm/auth-capture/client/scheme.go b/go/mechanisms/evm/auth-capture/client/scheme.go new file mode 100644 index 0000000000..5ea0b109c5 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/client/scheme.go @@ -0,0 +1,339 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// AuthCaptureEvmScheme implements SchemeNetworkClient for auth-capture EVM payments. +type AuthCaptureEvmScheme struct { + signer evm.ClientEvmSigner + now func() time.Time +} + +// NewAuthCaptureEvmScheme creates a client-side auth-capture scheme bound to signer. +func NewAuthCaptureEvmScheme(signer evm.ClientEvmSigner) *AuthCaptureEvmScheme { + return &AuthCaptureEvmScheme{ + signer: signer, + now: time.Now, + } +} + +// Scheme returns the scheme identifier. +func (c *AuthCaptureEvmScheme) Scheme() string { + return authcapture.SchemeAuthCapture +} + +func (c *AuthCaptureEvmScheme) FindDefaultAsset(asset string, network x402.Network) *x402.DefaultAsset { + info := evm.FindDefaultAsset(asset, string(network)) + if info == nil { + return nil + } + return &x402.DefaultAsset{Asset: info.Asset, Decimals: info.Decimals, Symbol: info.Symbol} +} + +// CreatePaymentPayload builds and signs an auth-capture collect payload for the given requirements. +func (c *AuthCaptureEvmScheme) CreatePaymentPayload( + ctx context.Context, + requirements types.PaymentRequirements, +) (types.PaymentPayload, error) { + extra, deployment, err := parseAuthCaptureExtra(requirements) + if err != nil { + return types.PaymentPayload{}, err + } + + if requirements.MaxTimeoutSeconds <= 0 { + return types.PaymentPayload{}, fmt.Errorf("'maxTimeoutSeconds' is required in PaymentRequirements (used to derive preApprovalExpiry)") + } + + chainID, err := evm.GetEvmChainId(string(requirements.Network)) + if err != nil { + return types.PaymentPayload{}, err + } + + maxAmount := requirements.Amount + nowSeconds := c.now().Unix() + preApprovalExpiry := uint64(nowSeconds + int64(requirements.MaxTimeoutSeconds)) + + assetTransferMethod := extra.AssetTransferMethod + if assetTransferMethod == "" { + assetTransferMethod = string(evm.AssetTransferMethodEIP3009) + } + + bindOn := authcapture.IsSaltBindingOn(extra) + saltNonce, err := authcapture.GenerateSalt() + if err != nil { + return types.PaymentPayload{}, err + } + + var salt string + if bindOn { + salt, err = authcapture.DeriveBoundSalt( + authcapture.ExtraAddress(extra.ReceiverAuthorizer), + authcapture.ExtraAddress(extra.Policy), + saltNonce, + ) + if err != nil { + return types.PaymentPayload{}, err + } + } else { + salt = saltNonce + } + + paymentInfo := authcapture.PaymentInfoStruct{ + Operator: extra.CaptureAuthorizer, + Payer: c.signer.Address(), + Receiver: requirements.PayTo, + Token: requirements.Asset, + MaxAmount: maxAmount, + PreApprovalExpiry: preApprovalExpiry, + AuthorizationExpiry: extra.CaptureDeadline, + RefundExpiry: extra.RefundDeadline, + MinFeeBps: extra.MinFeeBps, + MaxFeeBps: extra.MaxFeeBps, + FeeReceiver: extra.FeeRecipient, + Salt: salt, + } + + nonce, err := authcapture.ComputePayerAgnosticPaymentInfoHash(chainID, paymentInfo, deployment.Escrow) + if err != nil { + return types.PaymentPayload{}, err + } + + if assetTransferMethod == string(evm.AssetTransferMethodPermit2) { + return c.createPermit2Payload(ctx, requirements, bindOn, salt, saltNonce, nonce, preApprovalExpiry, chainID, deployment) + } + + return c.createEIP3009Payload(ctx, requirements, extra, bindOn, salt, saltNonce, nonce, preApprovalExpiry, chainID, deployment) +} + +func (c *AuthCaptureEvmScheme) createEIP3009Payload( + ctx context.Context, + requirements types.PaymentRequirements, + extra authcapture.AuthCaptureExtra, + bindOn bool, + salt string, + saltNonce string, + nonce string, + preApprovalExpiry uint64, + chainID *big.Int, + deployment authcapture.AuthCaptureDeployment, +) (types.PaymentPayload, error) { + authorization := authcapture.Eip3009Authorization{ + From: c.signer.Address(), + To: deployment.EIP3009Collector, + Value: requirements.Amount, + ValidAfter: "0", + ValidBefore: fmt.Sprintf("%d", preApprovalExpiry), + Nonce: nonce, + } + + signature, err := authcapture.SignERC3009(ctx, c.signer, authorization, extra, requirements.Asset, chainID) + if err != nil { + return types.PaymentPayload{}, fmt.Errorf("failed to sign ERC-3009 authorization: %w", err) + } + + payload := map[string]interface{}{ + "authorization": map[string]interface{}{ + "from": authorization.From, + "to": authorization.To, + "value": authorization.Value, + "validAfter": authorization.ValidAfter, + "validBefore": authorization.ValidBefore, + "nonce": authorization.Nonce, + }, + "signature": evm.BytesToHex(signature), + "salt": salt, + } + if bindOn { + payload["saltNonce"] = saltNonce + } + + return types.PaymentPayload{ + X402Version: 2, + Payload: payload, + }, nil +} + +func (c *AuthCaptureEvmScheme) createPermit2Payload( + ctx context.Context, + requirements types.PaymentRequirements, + bindOn bool, + salt string, + saltNonce string, + nonce string, + preApprovalExpiry uint64, + chainID *big.Int, + deployment authcapture.AuthCaptureDeployment, +) (types.PaymentPayload, error) { + permitNonce, err := authcapture.NonceHexToDecimalString(nonce) + if err != nil { + return types.PaymentPayload{}, err + } + + permit := authcapture.Permit2Authorization{ + From: c.signer.Address(), + Permitted: authcapture.Permit2TokenPermissions{ + Token: requirements.Asset, + Amount: requirements.Amount, + }, + Spender: deployment.Permit2Collector, + Nonce: permitNonce, + Deadline: fmt.Sprintf("%d", preApprovalExpiry), + } + + signature, err := authcapture.SignPermit2(ctx, c.signer, permit, chainID) + if err != nil { + return types.PaymentPayload{}, fmt.Errorf("failed to sign Permit2 authorization: %w", err) + } + + payload := map[string]interface{}{ + "permit2Authorization": map[string]interface{}{ + "from": permit.From, + "permitted": map[string]interface{}{ + "token": permit.Permitted.Token, + "amount": permit.Permitted.Amount, + }, + "spender": permit.Spender, + "nonce": permit.Nonce, + "deadline": permit.Deadline, + }, + "signature": evm.BytesToHex(signature), + "salt": salt, + } + if bindOn { + payload["saltNonce"] = saltNonce + } + + return types.PaymentPayload{ + X402Version: 2, + Payload: payload, + }, nil +} + +func parseAuthCaptureExtra(requirements types.PaymentRequirements) (authcapture.AuthCaptureExtra, authcapture.AuthCaptureDeployment, error) { + if requirements.Extra == nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") + } + ex := requirements.Extra + + name, _ := ex["name"].(string) + if name == "" { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'name' is required in payment requirements for asset %s", requirements.Asset) + } + version, _ := ex["version"].(string) + if version == "" { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'version' is required in payment requirements for asset %s", requirements.Asset) + } + + captureAuthorizer, _ := ex["captureAuthorizer"].(string) + if captureAuthorizer == "" { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") + } + feeRecipient, _ := ex["feeRecipient"].(string) + if feeRecipient == "" { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'feeRecipient' is required in payment requirements extra") + } + + captureDeadline, err := extraUint64(ex, "captureDeadline") + if err != nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureDeadline' is required in payment requirements extra") + } + refundDeadline, err := extraUint64(ex, "refundDeadline") + if err != nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'refundDeadline' is required in payment requirements extra") + } + minFeeBps, err := extraUint16(ex, "minFeeBps") + if err != nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'minFeeBps' is required in payment requirements extra") + } + maxFeeBps, err := extraUint16(ex, "maxFeeBps") + if err != nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'maxFeeBps' is required in payment requirements extra") + } + + authCaptureEscrow := stringFromExtra(ex, "authCaptureEscrow") + deployment := authcapture.ResolveAuthCaptureDeployment(authCaptureEscrow) + if deployment == nil { + return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("invalid authCaptureEscrow in payment requirements extra") + } + + extraOut := authcapture.AuthCaptureExtra{ + CaptureAuthorizer: captureAuthorizer, + CaptureDeadline: captureDeadline, + RefundDeadline: refundDeadline, + FeeRecipient: feeRecipient, + MinFeeBps: minFeeBps, + MaxFeeBps: maxFeeBps, + Name: name, + Version: version, + ReceiverAuthorizer: stringFromExtra(ex, "receiverAuthorizer"), + Policy: stringFromExtra(ex, "policy"), + PaymentFlow: stringFromExtra(ex, "paymentFlow"), + CaptureMode: stringFromExtra(ex, "captureMode"), + OperatorType: stringFromExtra(ex, "operatorType"), + AssetTransferMethod: stringFromExtra(ex, "assetTransferMethod"), + AuthCaptureEscrow: deployment.Escrow, + } + return extraOut, *deployment, nil +} + +func stringFromExtra(ex map[string]interface{}, key string) string { + if v, ok := ex[key].(string); ok { + return v + } + return "" +} + +func extraUint64(ex map[string]interface{}, key string) (uint64, error) { + value, ok := ex[key] + if !ok { + return 0, fmt.Errorf("missing %s", key) + } + switch v := value.(type) { + case float64: + if v < 0 || v != float64(uint64(v)) { + return 0, fmt.Errorf("invalid %s", key) + } + return uint64(v), nil + case int: + if v < 0 { + return 0, fmt.Errorf("invalid %s", key) + } + return uint64(v), nil + case int64: + if v < 0 { + return 0, fmt.Errorf("invalid %s", key) + } + return uint64(v), nil + case uint64: + return v, nil + case json.Number: + n, err := v.Int64() + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid %s", key) + } + return uint64(n), nil + default: + return 0, fmt.Errorf("invalid %s", key) + } +} + +func extraUint16(ex map[string]interface{}, key string) (uint16, error) { + n, err := extraUint64(ex, key) + if err != nil { + return 0, err + } + if n > 65535 { + return 0, fmt.Errorf("invalid %s", key) + } + return uint16(n), nil +} diff --git a/go/mechanisms/evm/auth-capture/client/scheme_test.go b/go/mechanisms/evm/auth-capture/client/scheme_test.go new file mode 100644 index 0000000000..b9b525598f --- /dev/null +++ b/go/mechanisms/evm/auth-capture/client/scheme_test.go @@ -0,0 +1,256 @@ +package client + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +type mockSigner struct { + address string + sig []byte + err error + + lastDomain evm.TypedDataDomain + lastTypes map[string][]evm.TypedDataField + lastPrimaryType string + lastMessage map[string]interface{} +} + +func (m *mockSigner) Address() string { return m.address } +func (m *mockSigner) SignTypedData(_ context.Context, domain evm.TypedDataDomain, types map[string][]evm.TypedDataField, primaryType string, message map[string]interface{}) ([]byte, error) { + m.lastDomain = domain + m.lastTypes = types + m.lastPrimaryType = primaryType + m.lastMessage = message + return m.sig, m.err +} + +func mockRequirements(extra map[string]interface{}) types.PaymentRequirements { + future := time.Now().Unix() + 86400 + baseExtra := map[string]interface{}{ + "captureAuthorizer": "0xcccccccccccccccccccccccccccccccccccccccc", + "captureDeadline": float64(future), + "refundDeadline": float64(future + 86400), + "feeRecipient": "0x4444444444444444444444444444444444444444", + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + "name": "USDC", + "version": "2", + } + for k, v := range extra { + baseExtra[k] = v + } + return types.PaymentRequirements{ + Scheme: authcapture.SchemeAuthCapture, + Network: "eip155:84532", + Amount: "1000000", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + PayTo: "0x1234567890123456789012345678901234567890", + MaxTimeoutSeconds: 3600, + Extra: baseExtra, + } +} + +func TestAuthCaptureEvmScheme_Scheme(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + if scheme.Scheme() != authcapture.SchemeAuthCapture { + t.Fatalf("scheme = %q", scheme.Scheme()) + } +} + +func TestCreatePaymentPayload_InvalidAuthCaptureEscrow(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + req := mockRequirements(map[string]interface{}{ + "authCaptureEscrow": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }) + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "authCaptureEscrow") { + t.Fatalf("expected authCaptureEscrow error, got %v", err) + } +} + +func TestCreatePaymentPayload_V1_0EscrowPin(t *testing.T) { + signer := &mockSigner{ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sig: []byte{0xde, 0xad, 0xbe, 0xef}, + } + scheme := NewAuthCaptureEvmScheme(signer) + scheme.now = func() time.Time { return time.Unix(1700000000, 0) } + + result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ + "authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address, + })) + if err != nil { + t.Fatalf("err: %v", err) + } + auth := result.Payload["authorization"].(map[string]interface{}) + if auth["to"] != authcapture.EIP3009TokenCollectorV1_0Address { + t.Fatalf("to = %v, want v1.0 collector", auth["to"]) + } +} + +func TestCreatePaymentPayload_EIP3009(t *testing.T) { + signer := &mockSigner{ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sig: []byte{0xde, 0xad, 0xbe, 0xef}, + } + scheme := NewAuthCaptureEvmScheme(signer) + scheme.now = func() time.Time { return time.Unix(1700000000, 0) } + + result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + if err != nil { + t.Fatalf("err: %v", err) + } + if result.X402Version != 2 { + t.Fatalf("x402Version = %d", result.X402Version) + } + if !authcapture.IsEip3009Payload(result.Payload) { + t.Fatalf("payload = %+v", result.Payload) + } + if result.Payload["signature"] != "0xdeadbeef" { + t.Fatalf("signature = %v", result.Payload["signature"]) + } + + auth := result.Payload["authorization"].(map[string]interface{}) + if auth["from"] != signer.address { + t.Fatalf("from = %v", auth["from"]) + } + if auth["to"] != authcapture.EIP3009TokenCollectorAddress { + t.Fatalf("to = %v", auth["to"]) + } + if auth["value"] != "1000000" { + t.Fatalf("value = %v", auth["value"]) + } + if auth["validBefore"] != "1700003600" { + t.Fatalf("validBefore = %v", auth["validBefore"]) + } + if _, ok := result.Payload["saltNonce"]; ok { + t.Fatal("expected no saltNonce for unbound salt") + } + if signer.lastPrimaryType != "ReceiveWithAuthorization" { + t.Fatalf("primaryType = %q", signer.lastPrimaryType) + } + if signer.lastDomain.Name != "USDC" || signer.lastDomain.Version != "2" { + t.Fatalf("domain = %+v", signer.lastDomain) + } + if signer.lastDomain.ChainID.Cmp(big.NewInt(84532)) != 0 { + t.Fatalf("chainId = %v", signer.lastDomain.ChainID) + } + if !strings.EqualFold(signer.lastDomain.VerifyingContract, "0x036CbD53842c5426634e7929541eC2318f3dCF7e") { + t.Fatalf("verifyingContract = %q", signer.lastDomain.VerifyingContract) + } +} + +func TestCreatePaymentPayload_MissingExtraFields(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + + req := mockRequirements(map[string]interface{}{"name": ""}) + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "name") { + t.Fatalf("expected name error, got %v", err) + } + + req = mockRequirements(map[string]interface{}{"version": ""}) + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "version") { + t.Fatalf("expected version error, got %v", err) + } + + req = mockRequirements(map[string]interface{}{"captureAuthorizer": ""}) + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "captureAuthorizer") { + t.Fatalf("expected captureAuthorizer error, got %v", err) + } + + req = mockRequirements(map[string]interface{}{"feeRecipient": ""}) + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "feeRecipient") { + t.Fatalf("expected feeRecipient error, got %v", err) + } +} + +func TestCreatePaymentPayload_BoundSalt(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sig: []byte{0x01}, + }) + result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ + "receiverAuthorizer": "0x1111111111111111111111111111111111111111", + })) + if err != nil { + t.Fatalf("err: %v", err) + } + saltNonce, ok := result.Payload["saltNonce"].(string) + if !ok || len(saltNonce) != 66 { + t.Fatalf("saltNonce = %v", result.Payload["saltNonce"]) + } + salt, ok := result.Payload["salt"].(string) + if !ok || len(salt) != 66 { + t.Fatalf("salt = %v", result.Payload["salt"]) + } + if salt == saltNonce { + t.Fatal("expected bound salt to differ from saltNonce") + } +} + +func TestCreatePaymentPayload_FreshUnboundSalt(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sig: []byte{0x01}, + }) + a, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + if err != nil { + t.Fatalf("err: %v", err) + } + b, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + if err != nil { + t.Fatalf("err: %v", err) + } + if a.Payload["salt"] == b.Payload["salt"] { + t.Fatal("expected fresh salt on each call") + } +} + +func TestCreatePaymentPayload_BadNetwork(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + req := mockRequirements(nil) + req.Network = "solana:mainnet" + if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "solana:mainnet") { + t.Fatalf("expected network error, got %v", err) + } +} + +func TestCreatePaymentPayload_Permit2(t *testing.T) { + signer := &mockSigner{ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sig: []byte{0xde, 0xad, 0xbe, 0xef}, + } + scheme := NewAuthCaptureEvmScheme(signer) + result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ + "assetTransferMethod": "permit2", + })) + if err != nil { + t.Fatalf("err: %v", err) + } + if !authcapture.IsPermit2Payload(result.Payload) { + t.Fatalf("payload = %+v", result.Payload) + } + auth := result.Payload["permit2Authorization"].(map[string]interface{}) + if auth["spender"] != authcapture.Permit2TokenCollectorAddress { + t.Fatalf("spender = %v", auth["spender"]) + } + if _, ok := new(big.Int).SetString(auth["nonce"].(string), 10); !ok { + t.Fatalf("nonce not decimal uint256 string: %v", auth["nonce"]) + } + if signer.lastPrimaryType != "PermitTransferFrom" { + t.Fatalf("primaryType = %q", signer.lastPrimaryType) + } + if signer.lastDomain.Name != "Permit2" { + t.Fatalf("domain name = %q", signer.lastDomain.Name) + } + if signer.lastDomain.VerifyingContract != evm.PERMIT2Address { + t.Fatalf("verifyingContract = %q", signer.lastDomain.VerifyingContract) + } +} diff --git a/go/mechanisms/evm/auth-capture/constants.go b/go/mechanisms/evm/auth-capture/constants.go new file mode 100644 index 0000000000..938edad548 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/constants.go @@ -0,0 +1,167 @@ +package authcapture + +import ( + "strings" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +const ( + // SchemeAuthCapture is the scheme identifier for auth-capture payments. + SchemeAuthCapture = "auth-capture" + + // AuthCaptureEscrowV1_0Address is the commerce-payments v1.0 AuthCaptureEscrow deployment. + AuthCaptureEscrowV1_0Address = "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff" + + // EIP3009TokenCollectorV1_0Address is the commerce-payments v1.0 EIP-3009 token collector. + EIP3009TokenCollectorV1_0Address = "0x0E3dF9510de65469C4518D7843919c0b8C7A7757" + + // Permit2TokenCollectorV1_0Address is the commerce-payments v1.0 Permit2 token collector. + Permit2TokenCollectorV1_0Address = "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26" + + // OperatorRefundCollectorV1_0Address is the commerce-payments v1.0 operator refund collector. + OperatorRefundCollectorV1_0Address = "0x934907bffd0901b6A21e398B9C53A4A38F02fa5d" + + // AuthCaptureEscrowV1_1Address is the commerce-payments v1.1 AuthCaptureEscrow deployment (default). + AuthCaptureEscrowV1_1Address = "0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB" + + // EIP3009TokenCollectorV1_1Address is the commerce-payments v1.1 EIP-3009 token collector. + EIP3009TokenCollectorV1_1Address = "0xEA902B37036bcb4944577ec2101ABdEDF56EbD28" + + // Permit2TokenCollectorV1_1Address is the commerce-payments v1.1 Permit2 token collector. + Permit2TokenCollectorV1_1Address = "0x1aacb38b16a1a8709e80746825E53A0C9Cae9b70" + + // OperatorRefundCollectorV1_1Address is the commerce-payments v1.1 operator refund collector. + OperatorRefundCollectorV1_1Address = "0x6a1ADdEEb4bD9c5811a613e20c172b6CE61A4aaB" + + // AuthCaptureEscrowAddress is the default AuthCaptureEscrow deployment (v1.1). + AuthCaptureEscrowAddress = AuthCaptureEscrowV1_1Address + + // EIP3009TokenCollectorAddress is the default EIP-3009 token collector (v1.1). + EIP3009TokenCollectorAddress = EIP3009TokenCollectorV1_1Address + + // Permit2TokenCollectorAddress is the default Permit2 token collector (v1.1). + Permit2TokenCollectorAddress = Permit2TokenCollectorV1_1Address + + // OperatorRefundCollectorAddress is the default operator refund collector (v1.1). + OperatorRefundCollectorAddress = OperatorRefundCollectorV1_1Address + + saltBindingTypeString = "x402AuthCaptureSaltBinding(address receiverAuthorizer,address policy,uint256 saltNonce)" + + paymentInfoTypeString = "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)" +) + +// AuthCaptureDeploymentVersion identifies a commerce-payments deployment set. +type AuthCaptureDeploymentVersion string + +const ( + AuthCaptureDeploymentV1_0 AuthCaptureDeploymentVersion = "v1.0" + AuthCaptureDeploymentV1_1 AuthCaptureDeploymentVersion = "v1.1" +) + +// AuthCaptureDeployment is a resolved commerce-payments deployment (escrow + collectors). +type AuthCaptureDeployment struct { + Version AuthCaptureDeploymentVersion + Escrow string + EIP3009Collector string + Permit2Collector string + OperatorRefundCollector string +} + +var ( + authCaptureDeploymentV1_0 = AuthCaptureDeployment{ + Version: AuthCaptureDeploymentV1_0, + Escrow: AuthCaptureEscrowV1_0Address, + EIP3009Collector: EIP3009TokenCollectorV1_0Address, + Permit2Collector: Permit2TokenCollectorV1_0Address, + OperatorRefundCollector: OperatorRefundCollectorV1_0Address, + } + + authCaptureDeploymentV1_1 = AuthCaptureDeployment{ + Version: AuthCaptureDeploymentV1_1, + Escrow: AuthCaptureEscrowV1_1Address, + EIP3009Collector: EIP3009TokenCollectorV1_1Address, + Permit2Collector: Permit2TokenCollectorV1_1Address, + OperatorRefundCollector: OperatorRefundCollectorV1_1Address, + } +) + +// ResolveAuthCaptureDeployment selects the commerce-payments deployment from +// optional extra.authCaptureEscrow. Absent or the v1.1 escrow selects v1.1; +// the v1.0 escrow selects v1.0. Returns nil for unknown or invalid addresses. +func ResolveAuthCaptureDeployment(escrow string) *AuthCaptureDeployment { + if escrow == "" { + d := authCaptureDeploymentV1_1 + return &d + } + if !evm.IsValidAddress(escrow) { + return nil + } + normalized := strings.ToLower(evm.NormalizeAddress(escrow)) + switch normalized { + case strings.ToLower(AuthCaptureEscrowV1_1Address), strings.ToLower(AuthCaptureEscrowAddress): + d := authCaptureDeploymentV1_1 + return &d + case strings.ToLower(AuthCaptureEscrowV1_0Address): + d := authCaptureDeploymentV1_0 + return &d + default: + return nil + } +} + +// SaltBindingTypeHash is keccak256(SALT_BINDING_TYPEHASH string). +var SaltBindingTypeHash = crypto.Keccak256Hash([]byte(saltBindingTypeString)) + +// PaymentInfoTypeHash is keccak256(PaymentInfo type string); must match AuthCaptureEscrow.PAYMENT_INFO_TYPEHASH. +var PaymentInfoTypeHash = crypto.Keccak256Hash([]byte(paymentInfoTypeString)) + +// ReceiveAuthorizationTypes defines EIP-712 types for ERC-3009 ReceiveWithAuthorization. +var ReceiveAuthorizationTypes = map[string][]evm.TypedDataField{ + "ReceiveWithAuthorization": { + {Name: "from", Type: "address"}, + {Name: "to", Type: "address"}, + {Name: "value", Type: "uint256"}, + {Name: "validAfter", Type: "uint256"}, + {Name: "validBefore", Type: "uint256"}, + {Name: "nonce", Type: "bytes32"}, + }, +} + +// Permit2TransferFromTypes defines EIP-712 types for Permit2 PermitTransferFrom (no witness). +var Permit2TransferFromTypes = map[string][]evm.TypedDataField{ + "PermitTransferFrom": { + {Name: "permitted", Type: "TokenPermissions"}, + {Name: "spender", Type: "address"}, + {Name: "nonce", Type: "uint256"}, + {Name: "deadline", Type: "uint256"}, + }, + "TokenPermissions": { + {Name: "token", Type: "address"}, + {Name: "amount", Type: "uint256"}, + }, +} + +// GetReceiveAuthorizationEIP712Types returns the complete EIP-712 types map for ERC-3009 signing. +func GetReceiveAuthorizationEIP712Types() map[string][]evm.TypedDataField { + return map[string][]evm.TypedDataField{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "ReceiveWithAuthorization": ReceiveAuthorizationTypes["ReceiveWithAuthorization"], + } +} + +// GetPermit2TransferFromEIP712Types returns the complete EIP-712 types map for Permit2 PermitTransferFrom. +func GetPermit2TransferFromEIP712Types() map[string][]evm.TypedDataField { + return map[string][]evm.TypedDataField{ + "EIP712Domain": evm.EIP712DomainTypes, + "PermitTransferFrom": Permit2TransferFromTypes["PermitTransferFrom"], + "TokenPermissions": Permit2TransferFromTypes["TokenPermissions"], + } +} diff --git a/go/mechanisms/evm/auth-capture/nonce.go b/go/mechanisms/evm/auth-capture/nonce.go new file mode 100644 index 0000000000..eca016b1ec --- /dev/null +++ b/go/mechanisms/evm/auth-capture/nonce.go @@ -0,0 +1,292 @@ +package authcapture + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +var ( + paymentInfoHashABI abi.Arguments + paymentInfoOuterABI abi.Arguments + saltBindingABI abi.Arguments +) + +func init() { + bytes32Ty, _ := abi.NewType("bytes32", "", nil) + addressTy, _ := abi.NewType("address", "", nil) + uint120Ty, _ := abi.NewType("uint120", "", nil) + uint48Ty, _ := abi.NewType("uint48", "", nil) + uint16Ty, _ := abi.NewType("uint16", "", nil) + uint256Ty, _ := abi.NewType("uint256", "", nil) + + paymentInfoHashABI = abi.Arguments{ + {Type: bytes32Ty}, + {Type: addressTy}, + {Type: addressTy}, + {Type: addressTy}, + {Type: addressTy}, + {Type: uint120Ty}, + {Type: uint48Ty}, + {Type: uint48Ty}, + {Type: uint48Ty}, + {Type: uint16Ty}, + {Type: uint16Ty}, + {Type: addressTy}, + {Type: uint256Ty}, + } + + paymentInfoOuterABI = abi.Arguments{ + {Type: uint256Ty}, + {Type: addressTy}, + {Type: bytes32Ty}, + } + + saltBindingABI = abi.Arguments{ + {Type: bytes32Ty}, + {Type: addressTy}, + {Type: addressTy}, + {Type: uint256Ty}, + } +} + +func hashPaymentInfo(chainID *big.Int, paymentInfo PaymentInfoStruct, payer string, escrowAddress string) (string, error) { + maxAmount, ok := new(big.Int).SetString(paymentInfo.MaxAmount, 10) + if !ok { + return "", fmt.Errorf("invalid maxAmount: %s", paymentInfo.MaxAmount) + } + saltBig, err := saltToBigInt(paymentInfo.Salt) + if err != nil { + return "", err + } + + encoded, err := paymentInfoHashABI.Pack( + PaymentInfoTypeHash, + common.HexToAddress(paymentInfo.Operator), + common.HexToAddress(payer), + common.HexToAddress(paymentInfo.Receiver), + common.HexToAddress(paymentInfo.Token), + maxAmount, + new(big.Int).SetUint64(paymentInfo.PreApprovalExpiry), + new(big.Int).SetUint64(paymentInfo.AuthorizationExpiry), + new(big.Int).SetUint64(paymentInfo.RefundExpiry), + paymentInfo.MinFeeBps, + paymentInfo.MaxFeeBps, + common.HexToAddress(paymentInfo.FeeReceiver), + saltBig, + ) + if err != nil { + return "", fmt.Errorf("failed to ABI-encode PaymentInfo: %w", err) + } + paymentInfoHash := crypto.Keccak256Hash(encoded) + + outerEncoded, err := paymentInfoOuterABI.Pack( + chainID, + common.HexToAddress(escrowAddress), + paymentInfoHash, + ) + if err != nil { + return "", fmt.Errorf("failed to ABI-encode payment info outer hash: %w", err) + } + return evm.BytesToHex(crypto.Keccak256(outerEncoded)), nil +} + +// ComputePayerAgnosticPaymentInfoHash returns the payer-agnostic PaymentInfo hash +// used as the ERC-3009 nonce and Permit2 nonce (as uint256). +func ComputePayerAgnosticPaymentInfoHash(chainID *big.Int, paymentInfo PaymentInfoStruct, escrowAddress ...string) (string, error) { + escrow := AuthCaptureEscrowAddress + if len(escrowAddress) > 0 && escrowAddress[0] != "" { + escrow = escrowAddress[0] + } + zero := "0x0000000000000000000000000000000000000000" + return hashPaymentInfo(chainID, paymentInfo, zero, escrow) +} + +// SignERC3009 signs ReceiveWithAuthorization with the token EIP-712 domain from extra. +func SignERC3009( + ctx context.Context, + signer evm.ClientEvmSigner, + authorization Eip3009Authorization, + extra AuthCaptureExtra, + tokenAddress string, + chainID *big.Int, +) ([]byte, error) { + domain := evm.TypedDataDomain{ + Name: extra.Name, + Version: extra.Version, + ChainID: chainID, + VerifyingContract: evm.NormalizeAddress(tokenAddress), + } + + value, ok := new(big.Int).SetString(authorization.Value, 10) + if !ok { + return nil, fmt.Errorf("invalid authorization value: %s", authorization.Value) + } + validAfter, ok := new(big.Int).SetString(authorization.ValidAfter, 10) + if !ok { + return nil, fmt.Errorf("invalid validAfter: %s", authorization.ValidAfter) + } + validBefore, ok := new(big.Int).SetString(authorization.ValidBefore, 10) + if !ok { + return nil, fmt.Errorf("invalid validBefore: %s", authorization.ValidBefore) + } + nonceBytes, err := evm.HexToBytes(authorization.Nonce) + if err != nil { + return nil, fmt.Errorf("invalid authorization nonce: %w", err) + } + + message := map[string]interface{}{ + "from": evm.NormalizeAddress(authorization.From), + "to": evm.NormalizeAddress(authorization.To), + "value": value, + "validAfter": validAfter, + "validBefore": validBefore, + "nonce": nonceBytes, + } + + return signer.SignTypedData( + ctx, + domain, + GetReceiveAuthorizationEIP712Types(), + "ReceiveWithAuthorization", + message, + ) +} + +// SignPermit2 signs PermitTransferFrom against the canonical Permit2 domain (no witness). +func SignPermit2( + ctx context.Context, + signer evm.ClientEvmSigner, + permit Permit2Authorization, + chainID *big.Int, +) ([]byte, error) { + domain := evm.TypedDataDomain{ + Name: "Permit2", + ChainID: chainID, + VerifyingContract: evm.PERMIT2Address, + } + + amount, ok := new(big.Int).SetString(permit.Permitted.Amount, 10) + if !ok { + return nil, fmt.Errorf("invalid permitted amount: %s", permit.Permitted.Amount) + } + nonce, ok := new(big.Int).SetString(permit.Nonce, 10) + if !ok { + return nil, fmt.Errorf("invalid permit nonce: %s", permit.Nonce) + } + deadline, ok := new(big.Int).SetString(permit.Deadline, 10) + if !ok { + return nil, fmt.Errorf("invalid permit deadline: %s", permit.Deadline) + } + + message := map[string]interface{}{ + "permitted": map[string]interface{}{ + "token": evm.NormalizeAddress(permit.Permitted.Token), + "amount": amount, + }, + "spender": evm.NormalizeAddress(permit.Spender), + "nonce": nonce, + "deadline": deadline, + } + + return signer.SignTypedData( + ctx, + domain, + GetPermit2TransferFromEIP712Types(), + "PermitTransferFrom", + message, + ) +} + +// GenerateSalt returns a fresh cryptographically-random 32-byte salt (0x-prefixed hex). +func GenerateSalt() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + return evm.BytesToHex(buf), nil +} + +// NormalizeBytes32 zero-pads a hex integer to a full 32-byte word. +func NormalizeBytes32(value string) (string, error) { + hexPart := strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X") + if len(hexPart) == 0 || len(hexPart) > 64 { + return "", fmt.Errorf("invalid bytes32: %s", value) + } + if _, err := hex.DecodeString(hexPart); err != nil { + return "", fmt.Errorf("invalid bytes32: %s", value) + } + return "0x" + strings.ToLower(strings.Repeat("0", 64-len(hexPart))+hexPart), nil +} + +// ExtraAddress treats absent or invalid values as the zero address. +func ExtraAddress(value string) string { + if value == "" || !evm.IsValidAddress(value) { + return "0x0000000000000000000000000000000000000000" + } + return evm.NormalizeAddress(value) +} + +// IsNonZeroAddress reports whether value is a valid non-zero EVM address. +func IsNonZeroAddress(value string) bool { + if value == "" || !evm.IsValidAddress(value) { + return false + } + return !strings.EqualFold(evm.NormalizeAddress(value), "0x0000000000000000000000000000000000000000") +} + +// IsSaltBindingOn is true when receiverAuthorizer or policy is non-zero. +func IsSaltBindingOn(extra AuthCaptureExtra) bool { + return IsNonZeroAddress(extra.ReceiverAuthorizer) || IsNonZeroAddress(extra.Policy) +} + +// DeriveBoundSalt returns keccak256(abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce)). +func DeriveBoundSalt(receiverAuthorizer, policy, saltNonce string) (string, error) { + saltNonceBig, err := saltToBigInt(saltNonce) + if err != nil { + return "", err + } + encoded, err := saltBindingABI.Pack( + SaltBindingTypeHash, + common.HexToAddress(receiverAuthorizer), + common.HexToAddress(policy), + saltNonceBig, + ) + if err != nil { + return "", fmt.Errorf("failed to ABI-encode bound salt: %w", err) + } + return evm.BytesToHex(crypto.Keccak256(encoded)), nil +} + +func saltToBigInt(salt string) (*big.Int, error) { + hexPart := strings.TrimPrefix(strings.TrimPrefix(salt, "0x"), "0X") + if hexPart == "" { + return nil, fmt.Errorf("invalid salt: %s", salt) + } + if _, err := hex.DecodeString(hexPart); err != nil { + return nil, fmt.Errorf("invalid salt: %s", salt) + } + saltBig, ok := new(big.Int).SetString(hexPart, 16) + if !ok { + return nil, fmt.Errorf("invalid salt: %s", salt) + } + return saltBig, nil +} + +// NonceHexToDecimalString interprets a 32-byte nonce hash as a uint256 decimal string. +func NonceHexToDecimalString(nonceHex string) (string, error) { + nonceBytes, err := evm.HexToBytes(nonceHex) + if err != nil { + return "", err + } + return new(big.Int).SetBytes(nonceBytes).String(), nil +} diff --git a/go/mechanisms/evm/auth-capture/nonce_test.go b/go/mechanisms/evm/auth-capture/nonce_test.go new file mode 100644 index 0000000000..5d27cfe350 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/nonce_test.go @@ -0,0 +1,238 @@ +package authcapture + +import ( + "math/big" + "testing" +) + +func TestResolveAuthCaptureDeployment(t *testing.T) { + v11 := ResolveAuthCaptureDeployment("") + if v11 == nil || v11.Version != AuthCaptureDeploymentV1_1 { + t.Fatalf("default deployment = %+v", v11) + } + if v11.Escrow != AuthCaptureEscrowV1_1Address { + t.Fatalf("default escrow = %q", v11.Escrow) + } + + pinnedV11 := ResolveAuthCaptureDeployment(AuthCaptureEscrowV1_1Address) + if pinnedV11 == nil || pinnedV11.Version != AuthCaptureDeploymentV1_1 { + t.Fatalf("v1.1 pin = %+v", pinnedV11) + } + + v10 := ResolveAuthCaptureDeployment(AuthCaptureEscrowV1_0Address) + if v10 == nil || v10.Version != AuthCaptureDeploymentV1_0 { + t.Fatalf("v1.0 pin = %+v", v10) + } + if v10.EIP3009Collector != EIP3009TokenCollectorV1_0Address { + t.Fatalf("v1.0 collector = %q", v10.EIP3009Collector) + } + + if ResolveAuthCaptureDeployment("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") != nil { + t.Fatal("expected unknown escrow to be rejected") + } + if ResolveAuthCaptureDeployment("not-an-address") != nil { + t.Fatal("expected invalid address to be rejected") + } +} + +func mockPaymentInfo() PaymentInfoStruct { + return PaymentInfoStruct{ + Operator: "0x1111111111111111111111111111111111111111", + Payer: "0xpppppppppppppppppppppppppppppppppppppppp", + Receiver: "0x2222222222222222222222222222222222222222", + Token: "0x3333333333333333333333333333333333333333", + MaxAmount: "1000000", + PreApprovalExpiry: 281474976710655, + AuthorizationExpiry: 281474976710655, + RefundExpiry: 281474976710655, + MinFeeBps: 0, + MaxFeeBps: 100, + FeeReceiver: "0x4444444444444444444444444444444444444444", + Salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Golden84532(t *testing.T) { + hash, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), mockPaymentInfo()) + if err != nil { + t.Fatalf("err: %v", err) + } + want := "0x341988b065a5131b3a82818eb7aba9010135f326af1af7695fce4d2bbebd0b76" + if hash != want { + t.Fatalf("hash = %q, want %q", hash, want) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Golden8453(t *testing.T) { + hash, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(8453), mockPaymentInfo()) + if err != nil { + t.Fatalf("err: %v", err) + } + want := "0xa393f8f76a2327a7678488b2d504bda611b7586bb3f334b255a11bb5a75e79ca" + if hash != want { + t.Fatalf("hash = %q, want %q", hash, want) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Golden84532_V1_0(t *testing.T) { + hash, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), mockPaymentInfo(), AuthCaptureEscrowV1_0Address) + if err != nil { + t.Fatalf("err: %v", err) + } + want := "0x19de8ffcb747e5caadb3dda7435cf54992e87cdf0c90e5315ffa129dbb22461e" + if hash != want { + t.Fatalf("hash = %q, want %q", hash, want) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Golden8453_V1_0(t *testing.T) { + hash, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(8453), mockPaymentInfo(), AuthCaptureEscrowV1_0Address) + if err != nil { + t.Fatalf("err: %v", err) + } + want := "0x198bbfeaab2f8e36302c662ae41bceaef47a1eb4bf2549cb87aa8daa7f7bb43a" + if hash != want { + t.Fatalf("hash = %q, want %q", hash, want) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Format(t *testing.T) { + hash, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), mockPaymentInfo()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(hash) != 66 || hash[:2] != "0x" { + t.Fatalf("unexpected hash format: %q", hash) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_Deterministic(t *testing.T) { + info := mockPaymentInfo() + a, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), info) + if err != nil { + t.Fatalf("err: %v", err) + } + b, err := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), info) + if err != nil { + t.Fatalf("err: %v", err) + } + if a != b { + t.Fatalf("hashes differ: %q vs %q", a, b) + } +} + +func TestComputePayerAgnosticPaymentInfoHash_DifferentChainID(t *testing.T) { + info := mockPaymentInfo() + a, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), info) + b, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(8453), info) + if a == b { + t.Fatal("expected different hashes for different chain IDs") + } +} + +func TestComputePayerAgnosticPaymentInfoHash_DifferentPaymentInfo(t *testing.T) { + info := mockPaymentInfo() + other := info + other.MaxAmount = "2000000" + a, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), info) + b, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), other) + if a == b { + t.Fatal("expected different hashes for different maxAmount") + } +} + +func TestComputePayerAgnosticPaymentInfoHash_DifferentSalt(t *testing.T) { + info := mockPaymentInfo() + other := info + other.Salt = "0x0000000000000000000000000000000000000000000000000000000000000002" + a, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), info) + b, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), other) + if a == b { + t.Fatal("expected different hashes for different salt") + } +} + +func TestComputePayerAgnosticPaymentInfoHash_PayerAgnostic(t *testing.T) { + base := mockPaymentInfo() + a := base + a.Payer = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + b := base + b.Payer = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + c := base + c.Payer = "0x0000000000000000000000000000000000000000" + + hashA, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), a) + hashB, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), b) + hashC, _ := ComputePayerAgnosticPaymentInfoHash(big.NewInt(84532), c) + if hashA != hashB || hashA != hashC { + t.Fatalf("expected payer-agnostic hashes to match: %q %q %q", hashA, hashB, hashC) + } +} + +func TestGenerateSalt(t *testing.T) { + s1, err := GenerateSalt() + if err != nil { + t.Fatalf("err: %v", err) + } + s2, err := GenerateSalt() + if err != nil { + t.Fatalf("err: %v", err) + } + if len(s1) != 66 || s1[:2] != "0x" { + t.Fatalf("bad salt format: %q", s1) + } + if s1 == s2 { + t.Fatal("expected unique salts") + } +} + +func TestSaltBinding(t *testing.T) { + authorizer := "0x1111111111111111111111111111111111111111" + zero := "0x0000000000000000000000000000000000000000" + nonce := "0x0000000000000000000000000000000000000000000000000000000000000abc" + + if IsSaltBindingOn(AuthCaptureExtra{}) { + t.Fatal("expected binding off for empty extra") + } + if IsSaltBindingOn(AuthCaptureExtra{ReceiverAuthorizer: zero, Policy: zero}) { + t.Fatal("expected binding off for zero addresses") + } + if !IsSaltBindingOn(AuthCaptureExtra{ReceiverAuthorizer: authorizer}) { + t.Fatal("expected binding on for non-zero receiverAuthorizer") + } + + a, err := DeriveBoundSalt(authorizer, zero, nonce) + if err != nil { + t.Fatalf("err: %v", err) + } + b, err := DeriveBoundSalt(authorizer, zero, nonce) + if err != nil { + t.Fatalf("err: %v", err) + } + if a != b { + t.Fatalf("expected deterministic bound salt, got %q vs %q", a, b) + } + want := "0xd0967e09b6c8fccf96277d95a03e98583e8605ab10858b1349aa50ea6d78132c" + if a != want { + t.Fatalf("bound salt = %q, want %q", a, want) + } + + base := a + changedAuthorizer, _ := DeriveBoundSalt("0x2222222222222222222222222222222222222222", zero, nonce) + if changedAuthorizer == base { + t.Fatal("expected bound salt to change when receiverAuthorizer changes") + } + changedNonce, _ := DeriveBoundSalt(authorizer, zero, "0x0000000000000000000000000000000000000000000000000000000000000abd") + if changedNonce == base { + t.Fatal("expected bound salt to change when saltNonce changes") + } +} + +func TestTypeHashConstants(t *testing.T) { + if SaltBindingTypeHash.Hex() != "0x8a2a7e41a0bda000ded071ff38b79401d2603e1826516ff2635b11fe9e30877f" { + t.Fatalf("SaltBindingTypeHash = %s", SaltBindingTypeHash.Hex()) + } + if PaymentInfoTypeHash.Hex() != "0xae68ac7ce30c86ece8196b61a7c486d8f0061f575037fbd34e7fe4e2820c6591" { + t.Fatalf("PaymentInfoTypeHash = %s", PaymentInfoTypeHash.Hex()) + } +} diff --git a/go/mechanisms/evm/auth-capture/types.go b/go/mechanisms/evm/auth-capture/types.go new file mode 100644 index 0000000000..5effae2d06 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/types.go @@ -0,0 +1,266 @@ +package authcapture + +import ( + "strings" +) + +// PaymentInfoStruct is the onchain PaymentInfo struct (canonical Solidity names). +type PaymentInfoStruct struct { + Operator string `json:"operator"` + Payer string `json:"payer"` + Receiver string `json:"receiver"` + Token string `json:"token"` + MaxAmount string `json:"maxAmount"` + PreApprovalExpiry uint64 `json:"preApprovalExpiry"` + AuthorizationExpiry uint64 `json:"authorizationExpiry"` + RefundExpiry uint64 `json:"refundExpiry"` + MinFeeBps uint16 `json:"minFeeBps"` + MaxFeeBps uint16 `json:"maxFeeBps"` + FeeReceiver string `json:"feeReceiver"` + Salt string `json:"salt"` +} + +// AuthCaptureExtra is the wire extra after facilitator enhancement (absolute deadlines). +type AuthCaptureExtra struct { + CaptureAuthorizer string + CaptureDeadline uint64 + RefundDeadline uint64 + FeeRecipient string + MinFeeBps uint16 + MaxFeeBps uint16 + Name string + Version string + PaymentFlow string + CaptureMode string + ReceiverAuthorizer string + Policy string + OperatorType string + AssetTransferMethod string + AuthCaptureEscrow string +} + +// Eip3009Authorization is the ERC-3009 ReceiveWithAuthorization message on the wire. +type Eip3009Authorization struct { + From string `json:"from"` + To string `json:"to"` + Value string `json:"value"` + ValidAfter string `json:"validAfter"` + ValidBefore string `json:"validBefore"` + Nonce string `json:"nonce"` +} + +// Permit2TokenPermissions is the permitted token/amount pair inside PermitTransferFrom. +type Permit2TokenPermissions struct { + Token string `json:"token"` + Amount string `json:"amount"` +} + +// Permit2Authorization is the Permit2 PermitTransferFrom message on the wire. +type Permit2Authorization struct { + From string `json:"from"` + Permitted Permit2TokenPermissions `json:"permitted"` + Spender string `json:"spender"` + Nonce string `json:"nonce"` + Deadline string `json:"deadline"` +} + +// IsAuthCaptureExtra reports whether value has every spec-mandated AuthCaptureExtra field. +func IsAuthCaptureExtra(value interface{}) bool { + m, ok := value.(map[string]interface{}) + if !ok { + return false + } + if !isNonEmptyString(m["captureAuthorizer"]) { + return false + } + if !isJSONNumber(m["captureDeadline"]) { + return false + } + if !isJSONNumber(m["refundDeadline"]) { + return false + } + if !isNonEmptyString(m["feeRecipient"]) { + return false + } + if !isJSONNumber(m["minFeeBps"]) { + return false + } + if !isJSONNumber(m["maxFeeBps"]) { + return false + } + if !isNonEmptyString(m["name"]) { + return false + } + return isNonEmptyString(m["version"]) +} + +func isNonEmptyString(value interface{}) bool { + s, ok := value.(string) + return ok && s != "" +} + +func isJSONNumber(value interface{}) bool { + switch value.(type) { + case float64, int, int64, uint64, uint16, int32, uint32: + return true + default: + return false + } +} + +func isHexString(value interface{}) bool { + s, ok := value.(string) + if !ok { + return false + } + if !strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X") { + return false + } + hexPart := s[2:] + if len(hexPart) == 0 || len(hexPart) > 64 { + return false + } + for _, c := range hexPart { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +func collectSaltFields(v map[string]interface{}) (saltNonce string, ok bool) { + if !isHexString(v["salt"]) { + return "", false + } + if v["saltNonce"] == nil { + return "", true + } + if !isHexString(v["saltNonce"]) { + return "", false + } + return v["saltNonce"].(string), true +} + +func readChargeCompletion(v map[string]interface{}) bool { + hasAny := false + for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { + if _, ok := v[key]; ok { + hasAny = true + break + } + } + if !hasAny { + return true + } + _, hasAmount := v["amount"].(string) + _, hasFeeBpsFloat := v["feeBps"].(float64) + _, hasFeeBpsInt := v["feeBps"].(int) + hasFeeBps := hasFeeBpsFloat || hasFeeBpsInt + _, hasFeeAmount := v["feeAmount"].(string) + _, hasFeeReceiver := v["feeReceiver"].(string) + _, hasAuthorizerSig := v["authorizerSignature"].(string) + if !hasAmount || !hasFeeReceiver || !hasAuthorizerSig { + return false + } + if hasFeeBps && !hasFeeAmount { + return true + } + if hasFeeAmount && !hasFeeBps { + return true + } + return false +} + +// IsEip3009Payload reports whether value is an EIP-3009-shaped auth-capture collect payload. +func IsEip3009Payload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok { + return false + } + if v["type"] != nil { + return false + } + auth, ok := v["authorization"].(map[string]interface{}) + if !ok || auth == nil { + return false + } + if _, ok := v["signature"].(string); !ok { + return false + } + saltNonce, ok := collectSaltFields(v) + if !ok { + return false + } + hasAnyCharge := false + for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { + if _, ok := v[key]; ok { + hasAnyCharge = true + break + } + } + if hasAnyCharge { + if saltNonce == "" { + return false + } + return readChargeCompletion(v) + } + return true +} + +// IsPermit2Payload reports whether value is a Permit2-shaped auth-capture collect payload. +func IsPermit2Payload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok { + return false + } + if v["type"] != nil { + return false + } + if _, ok := v["signature"].(string); !ok { + return false + } + auth, ok := v["permit2Authorization"].(map[string]interface{}) + if !ok || auth == nil { + return false + } + if _, ok := auth["from"].(string); !ok { + return false + } + if _, ok := auth["spender"].(string); !ok { + return false + } + if _, ok := auth["nonce"].(string); !ok { + return false + } + if _, ok := auth["deadline"].(string); !ok { + return false + } + permitted, ok := auth["permitted"].(map[string]interface{}) + if !ok || permitted == nil { + return false + } + if _, ok := permitted["token"].(string); !ok { + return false + } + if _, ok := permitted["amount"].(string); !ok { + return false + } + saltNonce, ok := collectSaltFields(v) + if !ok { + return false + } + hasAnyCharge := false + for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { + if _, ok := v[key]; ok { + hasAnyCharge = true + break + } + } + if hasAnyCharge { + if saltNonce == "" { + return false + } + return readChargeCompletion(v) + } + return true +} diff --git a/go/mechanisms/evm/auth-capture/types_test.go b/go/mechanisms/evm/auth-capture/types_test.go new file mode 100644 index 0000000000..87033a81cb --- /dev/null +++ b/go/mechanisms/evm/auth-capture/types_test.go @@ -0,0 +1,200 @@ +package authcapture + +import "testing" + +func TestIsAuthCaptureExtra(t *testing.T) { + future := float64(4102444800) + valid := map[string]interface{}{ + "captureAuthorizer": "0xcccccccccccccccccccccccccccccccccccccccc", + "captureDeadline": future, + "refundDeadline": future + 86400, + "feeRecipient": "0x4444444444444444444444444444444444444444", + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + "name": "USDC", + "version": "2", + } + + if !IsAuthCaptureExtra(valid) { + t.Fatal("expected valid extra") + } + if IsAuthCaptureExtra(nil) || IsAuthCaptureExtra("string") || IsAuthCaptureExtra(42) { + t.Fatal("expected non-object to be rejected") + } + + noAuthorizer := copyMap(valid) + delete(noAuthorizer, "captureAuthorizer") + if IsAuthCaptureExtra(noAuthorizer) { + t.Fatal("expected missing captureAuthorizer to be rejected") + } + + badDeadline := copyMap(valid) + badDeadline["captureDeadline"] = "soon" + if IsAuthCaptureExtra(badDeadline) { + t.Fatal("expected non-number captureDeadline to be rejected") + } + + badFeeRecipient := copyMap(valid) + badFeeRecipient["feeRecipient"] = 42 + if IsAuthCaptureExtra(badFeeRecipient) { + t.Fatal("expected non-string feeRecipient to be rejected") + } + + noName := copyMap(valid) + delete(noName, "name") + if IsAuthCaptureExtra(noName) { + t.Fatal("expected missing name to be rejected") + } + + noMinFee := copyMap(valid) + delete(noMinFee, "minFeeBps") + if IsAuthCaptureExtra(noMinFee) { + t.Fatal("expected missing minFeeBps to be rejected") + } + + noMaxFee := copyMap(valid) + delete(noMaxFee, "maxFeeBps") + if IsAuthCaptureExtra(noMaxFee) { + t.Fatal("expected missing maxFeeBps to be rejected") + } + + oldShape := map[string]interface{}{ + "escrowAddress": "0xeee", + "operatorAddress": "0xccc", + "tokenCollector": "0xbbb", + "name": "USDC", + "version": "2", + } + if IsAuthCaptureExtra(oldShape) { + t.Fatal("expected old commerce-era extra to be rejected") + } +} + +func TestIsEip3009Payload(t *testing.T) { + future := "4102444800" + valid := map[string]interface{}{ + "authorization": map[string]interface{}{ + "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "to": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "value": "1000000", + "validAfter": "0", + "validBefore": future, + "nonce": "0x1234567890123456789012345678901234567890123456789012345678901234", + }, + "signature": "0xabcd", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc", + } + + if !IsEip3009Payload(valid) { + t.Fatal("expected valid EIP-3009 payload") + } + + noAuth := copyMap(valid) + delete(noAuth, "authorization") + if IsEip3009Payload(noAuth) { + t.Fatal("expected missing authorization to be rejected") + } + + noSig := copyMap(valid) + delete(noSig, "signature") + if IsEip3009Payload(noSig) { + t.Fatal("expected missing signature to be rejected") + } + + noSalt := copyMap(valid) + delete(noSalt, "salt") + if IsEip3009Payload(noSalt) { + t.Fatal("expected missing salt to be rejected") + } + + withSaltNonce := copyMap(valid) + withSaltNonce["saltNonce"] = "0x0000000000000000000000000000000000000000000000000000000000000abc" + if !IsEip3009Payload(withSaltNonce) { + t.Fatal("expected bound payload with saltNonce") + } + + lifecycle := copyMap(valid) + lifecycle["type"] = "capture" + if IsEip3009Payload(lifecycle) { + t.Fatal("expected lifecycle payload to be rejected") + } + + if IsEip3009Payload(validPermit2()) || IsEip3009Payload(nil) { + t.Fatal("expected Permit2/null to be rejected") + } +} + +func TestIsPermit2Payload(t *testing.T) { + valid := validPermit2() + if !IsPermit2Payload(valid) { + t.Fatal("expected valid Permit2 payload") + } + + noPermit := copyMap(valid) + delete(noPermit, "permit2Authorization") + if IsPermit2Payload(noPermit) { + t.Fatal("expected missing permit2Authorization to be rejected") + } + + noSalt := copyMap(valid) + delete(noSalt, "salt") + if IsPermit2Payload(noSalt) { + t.Fatal("expected missing salt to be rejected") + } + + badFrom := copyMap(valid) + auth := copyMap(valid["permit2Authorization"].(map[string]interface{})) + auth["from"] = 42 + badFrom["permit2Authorization"] = auth + if IsPermit2Payload(badFrom) { + t.Fatal("expected non-string from to be rejected") + } + + if IsPermit2Payload(validEip3009()) { + t.Fatal("expected EIP-3009 payload to be rejected") + } +} + +func validEip3009() map[string]interface{} { + return map[string]interface{}{ + "authorization": map[string]interface{}{ + "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "to": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "value": "1000000", + "validAfter": "0", + "validBefore": "4102444800", + "nonce": "0x1234567890123456789012345678901234567890123456789012345678901234", + }, + "signature": "0xabcd", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc", + } +} + +func validPermit2() map[string]interface{} { + return map[string]interface{}{ + "permit2Authorization": map[string]interface{}{ + "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "permitted": map[string]interface{}{ + "token": "0xeeee", + "amount": "1000000", + }, + "spender": "0xdddd", + "nonce": "12345", + "deadline": "4102444800", + }, + "signature": "0xabcd", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc", + } +} + +func copyMap(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + if nested, ok := v.(map[string]interface{}); ok { + out[k] = copyMap(nested) + continue + } + out[k] = v + } + return out +} diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit.go index 3ffa39c2aa..a09be2ae7d 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit.go @@ -285,6 +285,29 @@ func VerifyDeposit( }, nil } +// depositSettlementCacheKey returns the unique-per-payload key used to key the +// PendingSettlementStore for a deposit settle: the payer's authorization +// signature (ERC-3009 or Permit2, whichever the payload carries). Returns "" +// when no signature is available (malformed payload), disabling the pending- +// settlement fast path for that call — the normal broadcast path still runs +// and surfaces the appropriate validation error. +func depositSettlementCacheKey( + payload *batchsettlement.BatchSettlementDepositPayload, + transferMethod batchsettlement.AssetTransferMethod, +) string { + switch transferMethod { + case batchsettlement.AssetTransferMethodEip3009: + if auth := payload.Deposit.Authorization.Erc3009Authorization; auth != nil { + return auth.Signature + } + case batchsettlement.AssetTransferMethodPermit2: + if auth := payload.Deposit.Authorization.Permit2Authorization; auth != nil { + return auth.Signature + } + } + return "" +} + // SettleDeposit executes a deposit onchain. // Calls deposit(config, amount, collector, collectorData) on the BatchSettlement contract. // @@ -294,6 +317,11 @@ func VerifyDeposit( // deposit() via `Erc20ApprovalGasSponsoringSigner.SendTransactions`) and the // EIP-2612 permit segment (encoded into `collectorData`). Both may be nil for // the standard Permit2 path or for ERC-3009 deposits. +// +// `store` is consulted first (keyed by depositSettlementCacheKey) to reconcile +// a previously-broadcast-but-unconfirmed deposit transaction from a prior +// settlement_pending response, instead of re-broadcasting. A nil store +// disables this fast path. func SettleDeposit( ctx context.Context, signer evm.FacilitatorEvmSigner, @@ -303,6 +331,7 @@ func SettleDeposit( fctx *x402.FacilitatorContext, dataSuffix []byte, allowedFactories []string, + store x402.PendingSettlementStore, ) (*x402.SettleResponse, error) { config := payload.ChannelConfig network := x402.Network(requirements.Network) @@ -342,6 +371,37 @@ func SettleDeposit( permit2Branch = branch } + cacheKey := depositSettlementCacheKey(payload, transferMethod) + receiptWaitSigner := signer + if permit2Branch != nil && permit2Branch.kind == permit2BranchErc20Approval { + receiptWaitSigner = permit2Branch.extensionSigner + } + + // Pending-settlement fast path: a prior settle for this exact authorization + // broadcast a transaction but couldn't confirm it in time. Reconcile against + // that transaction instead of re-broadcasting (which would revert on replay + // — the authorization's nonce/signature has already been consumed onchain). + if store != nil && cacheKey != "" { + if txHash, hit, _ := store.Get(ctx, cacheKey); hit { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + _ = store.Delete(ctx, cacheKey) + return reconcilePendingDeposit(ctx, depositSettleContext{ + signer: signer, + receiptWaitSigner: receiptWaitSigner, + config: config, + channelId: payload.Voucher.ChannelId, + network: network, + txHash: txHash, + amountStr: payload.Deposit.Amount, + store: store, + cacheKey: cacheKey, + }) + } + } + collectorAddr, collectorData, err := buildDepositCollectorCall(payload, transferMethod, permit2Branch) if err != nil { return nil, x402.NewSettleError(ErrInvalidDepositPayload, config.Payer, network, "", @@ -390,16 +450,13 @@ func SettleDeposit( // erc20Approval → broadcast pre-signed approve() then deposit() via the // facilitator extension signer's SendTransactions. // else → single deposit() write through the facilitator signer. - var ( - txHash string - receiptWaitSigner = signer - // A single hash from the two-request (approve + deposit) send means the signer - // bundled them atomically, but a non-conforming signer could return one hash - // after broadcasting only the approve. Its receipt then proves some transaction - // didn't revert, not that the deposit ran, so success requires the balance - // check below. - unconfirmedBundleHash bool - ) + var txHash string + // A single hash from the two-request (approve + deposit) send means the signer + // bundled them atomically, but a non-conforming signer could return one hash + // after broadcasting only the approve. Its receipt then proves some transaction + // didn't revert, not that the deposit ran, so success requires the balance + // check below. + unconfirmedBundleHash := false if permit2Branch != nil && permit2Branch.kind == permit2BranchErc20Approval { settleCall := erc20approvalgassponsor.WriteContractCall{ Address: batchsettlement.BatchSettlementAddress, @@ -422,7 +479,6 @@ func SettleDeposit( fmt.Sprintf("expected 1 (atomic bundle) or 2 (sequential) tx hashes from extension signer, got %d", len(txHashes))) } unconfirmedBundleHash = len(txHashes) == 1 - receiptWaitSigner = permit2Branch.extensionSigner } else { txHash, err = signer.WriteContract( ctx, @@ -441,7 +497,69 @@ func SettleDeposit( } } - if _, err := evm.WaitForSettleReceipt(ctx, receiptWaitSigner, txHash, config.Payer, network, + return finishDepositSettle(ctx, depositSettleContext{ + signer: signer, + receiptWaitSigner: receiptWaitSigner, + config: config, + channelId: payload.Voucher.ChannelId, + network: network, + txHash: txHash, + amountStr: payload.Deposit.Amount, + store: store, + cacheKey: cacheKey, + }, depositAmount, unconfirmedBundleHash, priorChannelState{ + balance: priorBalance, + totalClaimed: priorTotalClaimed, + withdrawRequestedAt: priorWithdrawRequestedAt, + refundNonce: priorRefundNonce, + }) +} + +// depositSettleContext holds the fields common to finishDepositSettle and +// reconcilePendingDeposit — the two ways a deposit settle attempt resolves: +// a fresh broadcast this call just made, or a pending-settlement store hit +// reconciling against one broadcast by a prior call. +type depositSettleContext struct { + signer evm.FacilitatorEvmSigner + receiptWaitSigner evm.FacilitatorEvmSigner + config batchsettlement.ChannelConfig + channelId string + network x402.Network + txHash string + amountStr string + store x402.PendingSettlementStore + cacheKey string +} + +// priorChannelState is the pre-broadcast channel-state snapshot used by +// finishDepositSettle's optimistic post-deposit fallback (see its doc comment +// for why it must be read strictly before the deposit broadcast). +type priorChannelState struct { + balance *big.Int + totalClaimed *big.Int + withdrawRequestedAt int + refundNonce *big.Int +} + +// finishDepositSettle waits for the deposit transaction to confirm, updates the +// PendingSettlementStore accordingly (recording the broadcast hash on a +// non-terminal wait failure so a subsequent settle attempt for the same +// authorization can reconcile instead of re-broadcasting, and clearing it once +// the receipt is observed), then polls channel state and builds the success +// response. +// +// prior must be read strictly before the deposit transaction was broadcast — +// it anchors the optimistic post-deposit fallback used when the post-receipt +// RPC read hasn't caught up yet. Reading it after broadcast could double-count +// (or under-count, for the erc20-approval-bundle ambiguity check) the deposit. +func finishDepositSettle( + ctx context.Context, + sc depositSettleContext, + depositAmount *big.Int, + unconfirmedBundleHash bool, + prior priorChannelState, +) (*x402.SettleResponse, error) { + if _, err := evm.WaitForSettleReceiptWithPendingStore(ctx, sc.store, sc.cacheKey, sc.receiptWaitSigner, sc.txHash, sc.config.Payer, sc.network, ErrDepositTransactionFailed, ErrTransactionReverted); err != nil { return nil, err } @@ -452,25 +570,25 @@ func SettleDeposit( // omits `chargedCumulativeAmount` — that field is added by the resource // server's `enrichSettlementResponse` hook, and emitting it from the // facilitator violates the additive-enrichment policy. - optimisticBalance := new(big.Int).Add(priorBalance, depositAmount) + optimisticBalance := new(big.Int).Add(prior.balance, depositAmount) optimisticState := &batchsettlement.ChannelState{ Balance: optimisticBalance, - TotalClaimed: priorTotalClaimed, - WithdrawRequestedAt: priorWithdrawRequestedAt, - RefundNonce: priorRefundNonce, + TotalClaimed: prior.totalClaimed, + WithdrawRequestedAt: prior.withdrawRequestedAt, + RefundNonce: prior.refundNonce, } // Poll the RPC until it reflects the just-confirmed deposit, so subsequent // verify reads are guaranteed to see this balance. expectedMinBalance := new(big.Int).Set(optimisticBalance) deadline := time.Now().Add(channelStatePollDeadline) - postState, readErr := ReadChannelState(ctx, signer, payload.Voucher.ChannelId) + postState, readErr := ReadChannelState(ctx, sc.signer, sc.channelId) for postState == nil || postState.Balance == nil || postState.Balance.Cmp(expectedMinBalance) < 0 { if time.Now().After(deadline) { break } time.Sleep(channelStatePollInterval) - postState, readErr = ReadChannelState(ctx, signer, payload.Voucher.ChannelId) + postState, readErr = ReadChannelState(ctx, sc.signer, sc.channelId) } balanceConfirmed := postState != nil && postState.Balance != nil && postState.Balance.Cmp(expectedMinBalance) >= 0 @@ -480,11 +598,20 @@ func SettleDeposit( // through to the optimistic state below. if unconfirmedBundleHash && !balanceConfirmed { if readErr == nil { - return nil, x402.NewSettleError(ErrDepositTransactionFailed, config.Payer, network, txHash, + return nil, x402.NewSettleError(ErrDepositTransactionFailed, sc.config.Payer, sc.network, sc.txHash, "extension signer returned a single transaction hash for the erc20 approval + deposit "+ "bundle, but the resulting channel balance does not reflect the deposit") } - return nil, x402.NewSettleError(ErrSettlementPending, config.Payer, network, txHash, + if sc.store != nil && sc.cacheKey != "" { + if setErr := sc.store.Set(ctx, sc.cacheKey, sc.txHash); setErr != nil { + // Can't guarantee a later retry will find this to reconcile against — a + // blind retry could re-verify/re-broadcast and double-send. Downgrade to + // terminal, preserving the transaction hash for manual reconciliation. + return nil, x402.NewSettleError(ErrDepositTransactionFailed, sc.config.Payer, sc.network, sc.txHash, + fmt.Sprintf("settlement_pending, but failed to persist for retry: %s", setErr.Error())) + } + } + return nil, x402.NewSettleError(ErrSettlementPending, sc.config.Payer, sc.network, sc.txHash, "extension signer returned a single transaction hash for the erc20 approval + deposit "+ "bundle and the post-deposit balance read failed, so the deposit could not be confirmed") } @@ -494,14 +621,62 @@ func SettleDeposit( finalState = postState } - extra := BuildSettleExtra(payload.Voucher.ChannelId, finalState) + extra := BuildSettleExtra(sc.channelId, finalState) + + return &x402.SettleResponse{ + Success: true, + Transaction: sc.txHash, + Network: sc.network, + Payer: sc.config.Payer, + Amount: sc.amountStr, + Extra: extra, + }, nil +} + +// reconcilePendingDeposit handles a PendingSettlementStore cache hit for +// SettleDeposit: a prior call already broadcast sc.txHash (its nonce/signature +// is now consumed onchain, so re-broadcasting is not an option) but couldn't +// confirm it before returning settlement_pending. Unlike finishDepositSettle, +// there is no reliable pre-broadcast channel-state snapshot available here (it +// lived in the earlier, now-returned call), so this path skips the optimistic +// balance-add fallback entirely: on confirmation it reports whatever the RPC +// currently reads (a short poll only guards against read-after-write lag), and +// on failure to confirm it re-records the pending entry and returns another +// settlement_pending for the caller to retry again later. +// +// Known limitation: finishDepositSettle's unconfirmedBundleHash check (guarding +// against a non-conforming ERC-20-approval extension signer that bundles a single +// hash covering only approve(), never running deposit()) has no equivalent here, +// since it needs the pre-broadcast channel state this path doesn't have — a +// receipt success here is trusted at face value. Only affects a non-conforming +// extension signer combined with a confirm-timeout on the original request. +func reconcilePendingDeposit(ctx context.Context, sc depositSettleContext) (*x402.SettleResponse, error) { + if _, err := evm.WaitForSettleReceiptWithPendingStore(ctx, sc.store, sc.cacheKey, sc.receiptWaitSigner, sc.txHash, sc.config.Payer, sc.network, + ErrDepositTransactionFailed, ErrTransactionReverted); err != nil { + return nil, err + } + + deadline := time.Now().Add(channelStatePollDeadline) + state, err := ReadChannelState(ctx, sc.signer, sc.channelId) + for state == nil && err != nil { + if time.Now().After(deadline) { + break + } + time.Sleep(channelStatePollInterval) + state, err = ReadChannelState(ctx, sc.signer, sc.channelId) + } + + var extra map[string]interface{} + if state != nil { + extra = BuildSettleExtra(sc.channelId, state) + } return &x402.SettleResponse{ Success: true, - Transaction: txHash, - Network: network, - Payer: config.Payer, - Amount: payload.Deposit.Amount, + Transaction: sc.txHash, + Network: sc.network, + Payer: sc.config.Payer, + Amount: sc.amountStr, Extra: extra, }, nil } diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit_pending_settlement_test.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit_pending_settlement_test.go new file mode 100644 index 0000000000..a6d51193c7 --- /dev/null +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit_pending_settlement_test.go @@ -0,0 +1,208 @@ +package facilitator + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + batchsettlement "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement" +) + +// Exercises the PendingSettlementStore fast path wired into SettleDeposit (see +// deposit.go): a settle attempt whose receipt wait fails must populate the +// store keyed by the deposit authorization signature; a subsequent settle for +// the identical payload must hit that entry, skip verify/broadcast entirely, +// and reconcile against the already-broadcast transaction via +// reconcilePendingDeposit. Mirrors the TS/Python batch-settlement deposit +// pending-settlement test suites. + +func pendingDepositPayload() (string, *batchsettlement.BatchSettlementDepositPayload) { + channelId := testErc3009ChannelId + auth := goodErc3009Auth() + payload := &batchsettlement.BatchSettlementDepositPayload{ + Type: "deposit", + ChannelConfig: goodErc3009Config(), + Voucher: batchsettlement.BatchSettlementVoucherFields{ + ChannelId: channelId, + MaxClaimableAmount: "100", + Signature: "0x" + strings.Repeat("22", 65), + }, + Deposit: batchsettlement.BatchSettlementDepositData{ + Amount: "100", + Authorization: batchsettlement.BatchSettlementDepositAuthorization{ + Erc3009Authorization: auth, + }, + }, + } + return auth.Signature, payload +} + +// depositConfirmedChannelStateReader reports an empty channel pre-broadcast and +// a balance reflecting the deposit amount once broadcast (tracked via +// writeSeen), so finishDepositSettle's post-receipt poll (see deposit.go) +// observes the expected balance on its first read instead of spinning until +// channelStatePollDeadline. Reconciliation (reconcilePendingDeposit) has no +// pre-broadcast snapshot of its own and exits its poll on the first +// successful read regardless of balance, so this also satisfies that path. +func depositConfirmedChannelStateReader(t *testing.T, writeSeen *bool) func(functionName string, _ ...interface{}) (interface{}, error) { + return func(functionName string, _ ...interface{}) (interface{}, error) { + if functionName != evm.FunctionTryAggregate { + return nil, errors.New("unexpected rpc") + } + balance := big.NewInt(0) + if writeSeen == nil || *writeSeen { + balance = big.NewInt(100) + } + return multicallChannelStateResult(t, balance, big.NewInt(0), 0, big.NewInt(0)), nil + } +} + +func TestSettleDeposit_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + sig, payload := pendingDepositPayload() + store := x402.NewInMemoryPendingSettlementStore() + writeSeen := false + signer := &fakeFacilitatorSigner{ + addresses: []string{"0xfacilitator"}, + readContract: depositConfirmedChannelStateReader(t, &writeSeen), + writeContract: func(string, ...interface{}) (string, error) { + writeSeen = true + return "0x" + strings.Repeat("ab", 32), nil + }, + waitForReceipt: func(txHash string) (*evm.TransactionReceipt, error) { + return &evm.TransactionReceipt{Status: evm.TxStatusSuccess, TxHash: txHash}, nil + }, + } + + resp, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, store) + if err != nil { + t.Fatalf("SettleDeposit: %v", err) + } + if !resp.Success { + t.Fatalf("expected success, got %+v", resp) + } + + if _, ok, _ := store.Get(context.Background(), sig); ok { + t.Error("successful settlement must not leave a pending entry") + } +} + +func TestSettleDeposit_PendingSettlementStore_CacheMissReceiptFailurePopulatesStore(t *testing.T) { + sig, payload := pendingDepositPayload() + store := x402.NewInMemoryPendingSettlementStore() + wantTxHash := "0x" + strings.Repeat("ab", 32) + signer := &fakeFacilitatorSigner{ + addresses: []string{"0xfacilitator"}, + readContract: depositConfirmedChannelStateReader(t, nil), + writeContract: func(string, ...interface{}) (string, error) { return wantTxHash, nil }, + waitForReceipt: func(string) (*evm.TransactionReceipt, error) { + return nil, errors.New("rpc: timeout waiting for receipt") + }, + } + + _, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, store) + var se *x402.SettleError + if !errors.As(err, &se) || se.ErrorReason != ErrSettlementPending { + t.Fatalf("got err = %v, want settlement_pending", err) + } + if se.Transaction != wantTxHash { + t.Fatalf("transaction = %q, want %q", se.Transaction, wantTxHash) + } + + txHash, ok, _ := store.Get(context.Background(), sig) + if !ok { + t.Fatal("receipt-wait failure must populate the pending-settlement store") + } + if txHash != wantTxHash { + t.Errorf("stored tx hash = %q, want %q", txHash, wantTxHash) + } +} + +func TestSettleDeposit_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + sig, payload := pendingDepositPayload() + store := x402.NewInMemoryPendingSettlementStore() + priorTxHash := "0x" + strings.Repeat("ab", 32) + if err := store.Set(context.Background(), sig, priorTxHash); err != nil { + t.Fatalf("store.Set: %v", err) + } + signer := &fakeFacilitatorSigner{ + addresses: []string{"0xfacilitator"}, + readContract: depositConfirmedChannelStateReader(t, nil), + waitForReceipt: func(txHash string) (*evm.TransactionReceipt, error) { + return &evm.TransactionReceipt{Status: evm.TxStatusSuccess, TxHash: txHash}, nil + }, + } + + resp, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, store) + if err != nil { + t.Fatalf("SettleDeposit: %v", err) + } + if !resp.Success || resp.Transaction != priorTxHash { + t.Fatalf("expected reconciled success with tx %q, got %+v", priorTxHash, resp) + } + if signer.writeCalls != 0 { + t.Errorf("reconciliation fast path must never re-broadcast, got %d WriteContract calls", signer.writeCalls) + } + + if _, ok, _ := store.Get(context.Background(), sig); ok { + t.Error("successful reconciliation must clear the pending entry") + } +} + +func TestSettleDeposit_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + sig, payload := pendingDepositPayload() + store := x402.NewInMemoryPendingSettlementStore() + priorTxHash := "0x" + strings.Repeat("ab", 32) + if err := store.Set(context.Background(), sig, priorTxHash); err != nil { + t.Fatalf("store.Set: %v", err) + } + signer := &fakeFacilitatorSigner{ + addresses: []string{"0xfacilitator"}, + readContract: depositConfirmedChannelStateReader(t, nil), + waitForReceipt: func(string) (*evm.TransactionReceipt, error) { return nil, errors.New("rpc: still pending") }, + } + + _, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, store) + var se *x402.SettleError + if !errors.As(err, &se) || se.ErrorReason != ErrSettlementPending { + t.Fatalf("got err = %v, want settlement_pending", err) + } + if se.Transaction != priorTxHash { + t.Fatalf("transaction = %q, want %q", se.Transaction, priorTxHash) + } + if signer.writeCalls != 0 { + t.Errorf("reconciliation fast path must never re-broadcast, got %d WriteContract calls", signer.writeCalls) + } + + txHash, ok, _ := store.Get(context.Background(), sig) + if !ok || txHash != priorTxHash { + t.Errorf("expected pending entry to persist with tx %q, got ok=%v tx=%q", priorTxHash, ok, txHash) + } +} + +func TestSettleDeposit_PendingSettlementStore_NilStoreDisablesFastPath(t *testing.T) { + _, payload := pendingDepositPayload() + writeSeen := false + signer := &fakeFacilitatorSigner{ + addresses: []string{"0xfacilitator"}, + readContract: depositConfirmedChannelStateReader(t, &writeSeen), + writeContract: func(string, ...interface{}) (string, error) { + writeSeen = true + return "0x" + strings.Repeat("ab", 32), nil + }, + waitForReceipt: func(txHash string) (*evm.TransactionReceipt, error) { + return &evm.TransactionReceipt{Status: evm.TxStatusSuccess, TxHash: txHash}, nil + }, + } + + resp, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, nil) + if err != nil { + t.Fatalf("SettleDeposit: %v", err) + } + if !resp.Success { + t.Errorf("expected success, got %+v", resp) + } +} diff --git a/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go b/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go index 9717f76084..c18cd1657f 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go @@ -421,7 +421,7 @@ func TestSettleDeposit_BadAmount(t *testing.T) { Amount: "not-a-number", }, } - _, err := SettleDeposit(context.Background(), scheme.signer, payload, reqsFor(testNetwork), nil, nil, nil, nil) + _, err := SettleDeposit(context.Background(), scheme.signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, nil) var se *x402.SettleError if !errors.As(err, &se) || se.ErrorReason != ErrInvalidDepositPayload { t.Fatalf("got err = %v", err) @@ -439,7 +439,7 @@ func TestSettleDeposit_MissingAuthorization(t *testing.T) { Amount: "100", }, } - _, err := SettleDeposit(context.Background(), scheme.signer, payload, reqsFor(testNetwork), nil, nil, nil, nil) + _, err := SettleDeposit(context.Background(), scheme.signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, nil) var se *x402.SettleError if !errors.As(err, &se) || se.ErrorReason != ErrInvalidDepositPayload { t.Fatalf("got err = %v", err) @@ -504,7 +504,7 @@ func TestSettleDeposit_PostReceiptBalanceNotDoubled(t *testing.T) { }, } - resp, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil) + resp, err := SettleDeposit(context.Background(), signer, payload, reqsFor(testNetwork), nil, nil, nil, nil, nil) if err != nil { t.Fatalf("SettleDeposit: %v", err) } @@ -580,7 +580,7 @@ func TestSettleDeposit_Erc20ApprovalAcceptsSingleExtensionHash(t *testing.T) { resp, err := SettleDeposit( context.Background(), signer, payload, reqsFor(testNetwork), - extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, + extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, nil, ) if err != nil { t.Fatalf("SettleDeposit: %v", err) @@ -639,7 +639,7 @@ func TestSettleDeposit_Erc20ApprovalSingleHashWithoutBalanceIncreaseFails(t *tes _, err := SettleDeposit( context.Background(), signer, payload, reqsFor(testNetwork), - extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, + extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, nil, ) var se *x402.SettleError if !errors.As(err, &se) || se.ErrorReason != ErrDepositTransactionFailed { @@ -695,7 +695,7 @@ func TestSettleDeposit_Erc20ApprovalSingleHashWithReadErrorReturnsSettlementPend _, err := SettleDeposit( context.Background(), signer, payload, reqsFor(testNetwork), - extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, + extensionsWithErc20Approval(goodErc20ApprovalInfo()), fctx, nil, nil, nil, ) var se *x402.SettleError if !errors.As(err, &se) || se.ErrorReason != ErrSettlementPending { diff --git a/go/mechanisms/evm/batch-settlement/facilitator/scheme.go b/go/mechanisms/evm/batch-settlement/facilitator/scheme.go index 2b9971ba7a..0f31540dad 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/scheme.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/scheme.go @@ -25,6 +25,7 @@ type BatchSettlementEvmScheme struct { signer evm.FacilitatorEvmSigner authorizerSigner batchsettlement.AuthorizerSigner config BatchSettlementEvmSchemeConfig + pendingStore x402.PendingSettlementStore } // NewBatchSettlementEvmScheme creates a new batch settlement facilitator scheme. @@ -34,7 +35,11 @@ type BatchSettlementEvmScheme struct { // server omits signatures from the payload. When nil, no receiverAuthorizer is // advertised and servers must supply their own authorizer signatures. func NewBatchSettlementEvmScheme(signer evm.FacilitatorEvmSigner, authorizerSigner batchsettlement.AuthorizerSigner) *BatchSettlementEvmScheme { - return &BatchSettlementEvmScheme{signer: signer, authorizerSigner: authorizerSigner} + return &BatchSettlementEvmScheme{ + signer: signer, + authorizerSigner: authorizerSigner, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + } } // NewBatchSettlementEvmSchemeWithConfig creates a batch settlement facilitator scheme with @@ -46,13 +51,29 @@ func NewBatchSettlementEvmSchemeWithConfig( authorizerSigner batchsettlement.AuthorizerSigner, config *BatchSettlementEvmSchemeConfig, ) *BatchSettlementEvmScheme { - s := &BatchSettlementEvmScheme{signer: signer, authorizerSigner: authorizerSigner} + s := &BatchSettlementEvmScheme{ + signer: signer, + authorizerSigner: authorizerSigner, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + } if config != nil { s.config = *config } return s } +// SetPendingSettlementStore overrides the default in-memory PendingSettlementStore +// used to reconcile deposit transactions that broadcast successfully but returned +// settlement_pending (e.g. a receipt-wait timeout). Only the deposit settle path +// consults this store; claim/settle/refund are single-signature onchain calls with +// no equivalent broadcast-then-reconcile flow. A nil store is a no-op, preserving +// the default in-memory instance. +func (f *BatchSettlementEvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store + } +} + // Scheme returns the scheme identifier. func (f *BatchSettlementEvmScheme) Scheme() string { return batchsettlement.SchemeBatched @@ -154,7 +175,7 @@ func (f *BatchSettlementEvmScheme) Settle( return nil, x402.NewSettleError(ErrInvalidDepositPayload, "", network, "", fmt.Sprintf("failed to parse deposit payload: %s", err)) } - return SettleDeposit(ctx, f.signer, depositPayload, requirements, payload.Extensions, fctx, dataSuffix, f.config.EIP6492AllowedFactories) + return SettleDeposit(ctx, f.signer, depositPayload, requirements, payload.Extensions, fctx, dataSuffix, f.config.EIP6492AllowedFactories, f.pendingStore) } // Enriched refund settle-action (must be checked BEFORE plain claim, since both diff --git a/go/mechanisms/evm/constants.go b/go/mechanisms/evm/constants.go index 49f7c0816c..892b282cd5 100644 --- a/go/mechanisms/evm/constants.go +++ b/go/mechanisms/evm/constants.go @@ -6,9 +6,10 @@ import ( const ( // Scheme identifiers - SchemeExact = "exact" - SchemeUpto = "upto" - SchemeBatched = "batch-settlement" + SchemeExact = "exact" + SchemeUpto = "upto" + SchemeBatched = "batch-settlement" + SchemeAuthCapture = "auth-capture" // Default token decimals for USDC DefaultDecimals = 6 diff --git a/go/mechanisms/evm/exact/client/scheme.go b/go/mechanisms/evm/exact/client/scheme.go index f593e0f709..d88773c9c5 100644 --- a/go/mechanisms/evm/exact/client/scheme.go +++ b/go/mechanisms/evm/exact/client/scheme.go @@ -276,7 +276,8 @@ func (c *ExactEvmScheme) createEIP3009Payload( } // V2 specific: No buffer on validAfter (can use immediately) - validAfter, validBefore := evm.CreateValidityWindow(time.Hour) + timeoutDuration := time.Duration(requirements.MaxTimeoutSeconds) * time.Second + validAfter, validBefore := evm.CreateValidityWindow(timeoutDuration) // Extract extra fields for EIP-3009 tokenName := assetInfo.Name diff --git a/go/mechanisms/evm/exact/facilitator/eip3009.go b/go/mechanisms/evm/exact/facilitator/eip3009.go index 2873d46e0a..ed6524ba9c 100644 --- a/go/mechanisms/evm/exact/facilitator/eip3009.go +++ b/go/mechanisms/evm/exact/facilitator/eip3009.go @@ -84,6 +84,13 @@ func (f *ExactEvmScheme) verifyEIP3009( return nil, x402.NewVerifyError(ErrInvalidSignatureFormat, evmPayload.Authorization.From, err.Error()) } + // Run the asset-contract check concurrently with signature classification. + assetCheckCh := make(chan assetContractCheck, 1) + go func() { + reason, err := evm.ValidateAssetIsContract(ctx, f.signer, requirements.Asset) + assetCheckCh <- assetContractCheck{reason: reason, err: err} + }() + classification, err := ClassifyEIP3009Signature( ctx, f.signer, @@ -115,10 +122,12 @@ func (f *ExactEvmScheme) verifyEIP3009( } } - if errReason, err := evm.ValidateAssetIsContract(ctx, f.signer, requirements.Asset); err != nil { - return nil, fmt.Errorf("asset contract check failed: %w", err) - } else if errReason != "" { - return nil, x402.NewVerifyError(errReason, evmPayload.Authorization.From, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) + assetResult := <-assetCheckCh + if assetResult.err != nil { + return nil, fmt.Errorf("asset contract check failed: %w", assetResult.err) + } + if assetResult.reason != "" { + return nil, x402.NewVerifyError(assetResult.reason, evmPayload.Authorization.From, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) } if simulate { @@ -161,6 +170,22 @@ func (f *ExactEvmScheme) settleEIP3009( ) (*x402.SettleResponse, error) { network := x402.Network(payload.Accepted.Network) + // Fast path: a prior settle attempt for this exact payload already broadcast + // a transaction whose receipt wait failed (settlement_pending). The resource + // server's single automatic retry resends the identical payload, so check the + // pending-settlement store before re-verifying/re-broadcasting — reconcile + // against the already-broadcast transaction instead of creating a second one. + if evmPayload, parseErr := evm.PayloadFromMap(payload.Payload); parseErr == nil && evmPayload.Signature != "" { + if txHash, ok, _ := f.pendingStore.Get(ctx, evmPayload.Signature); ok { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + _ = f.pendingStore.Delete(ctx, evmPayload.Signature) + return f.reconcilePendingEIP3009(ctx, evmPayload, requirements, network, txHash) + } + } + verifyResp, err := f.verifyEIP3009(ctx, payload, requirements, f.config.SimulateInSettle) if err != nil { ve := &x402.VerifyError{} @@ -229,7 +254,46 @@ func (f *ExactEvmScheme) settleEIP3009( return nil, x402.NewSettleError(parseEIP3009TransferError(err), verifyResp.Payer, network, "", err.Error()) } - receipt, err := evm.WaitForSettleReceipt(ctx, f.signer, txHash, verifyResp.Payer, network, + return f.awaitEIP3009Settlement(ctx, evmPayload.Signature, tokenAddress, parsedAuthorization, network, verifyResp.Payer, txHash) +} + +// reconcilePendingEIP3009 handles a pending-settlement store hit: it skips +// verify and broadcast entirely (the payer is taken directly from the +// payload, exactly as the original attempt did) and awaits the previously +// broadcast transaction. +func (f *ExactEvmScheme) reconcilePendingEIP3009( + ctx context.Context, + evmPayload *evm.ExactEIP3009Payload, + requirements types.PaymentRequirements, + network x402.Network, + txHash string, +) (*x402.SettleResponse, error) { + tokenAddress := evm.NormalizeAddress(requirements.Asset) + parsedAuthorization, err := ParseEIP3009Authorization(evmPayload.Authorization) + if err != nil { + return nil, x402.NewSettleError(ErrInvalidPayload, evmPayload.Authorization.From, network, "", err.Error()) + } + return f.awaitEIP3009Settlement(ctx, evmPayload.Signature, tokenAddress, parsedAuthorization, network, evmPayload.Authorization.From, txHash) +} + +// awaitEIP3009Settlement waits for the broadcast transaction's receipt (via +// WaitForSettleReceiptWithPendingStore) and additionally verifies its +// Transfer event, shared by both the normal broadcast path and the +// pending-settlement reconciliation path above. A confirmed-but-mismatched +// receipt is terminal and clears the pending entry (unlike a receipt-wait +// failure, which WaitForSettleReceiptWithPendingStore already records for +// reconciliation); an unparseable-but-successful receipt re-records it as +// non-terminal, since the transfer's effect is unknown. +func (f *ExactEvmScheme) awaitEIP3009Settlement( + ctx context.Context, + pendingKey string, + tokenAddress string, + parsedAuthorization *ParsedEIP3009Authorization, + network x402.Network, + payer string, + txHash string, +) (*x402.SettleResponse, error) { + receipt, err := evm.WaitForSettleReceiptWithPendingStore(ctx, f.pendingStore, pendingKey, f.signer, txHash, payer, network, ErrTransactionFailed, ErrTransactionFailed) if err != nil { return nil, err @@ -244,11 +308,19 @@ func (f *ExactEvmScheme) settleEIP3009( if err != nil { // The receipt succeeded but its logs could not be parsed, so the transfer's effect // is unknown. A parsed-but-absent event below is terminal; this is not. - return nil, x402.NewSettleError(ErrSettlementPending, verifyResp.Payer, network, txHash, + if setErr := f.pendingStore.Set(ctx, pendingKey, txHash); setErr != nil { + // Can't guarantee a later retry will find this to reconcile against — a + // blind retry could re-verify/re-broadcast and double-send. Downgrade to + // terminal, preserving the transaction hash for manual reconciliation. + return nil, x402.NewSettleError(ErrTransactionFailed, payer, network, txHash, + fmt.Sprintf("settlement_pending, but failed to persist for retry: %s", setErr.Error())) + } + return nil, x402.NewSettleError(ErrSettlementPending, payer, network, txHash, evm.TruncateErrorMessage(err.Error())) } if !transferMatched { - return nil, x402.NewSettleError(ErrTransferEventMismatch, verifyResp.Payer, network, txHash, "") + _ = f.pendingStore.Delete(ctx, pendingKey) + return nil, x402.NewSettleError(ErrTransferEventMismatch, payer, network, txHash, "") } } @@ -256,6 +328,6 @@ func (f *ExactEvmScheme) settleEIP3009( Success: true, Transaction: txHash, Network: network, - Payer: verifyResp.Payer, + Payer: payer, }, nil } diff --git a/go/mechanisms/evm/exact/facilitator/eip3009_helpers.go b/go/mechanisms/evm/exact/facilitator/eip3009_helpers.go index 99746cf670..f11e663630 100644 --- a/go/mechanisms/evm/exact/facilitator/eip3009_helpers.go +++ b/go/mechanisms/evm/exact/facilitator/eip3009_helpers.go @@ -172,11 +172,8 @@ func ClassifyEIP3009Signature( return classification, nil } - code, err := signer.GetCode(ctx, authorization.From) - if err != nil { - return nil, err - } - if len(code) > 0 { + // Reuse the GetCode result VerifyUniversalSignature already fetched. + if sigData.CodeDeployed { classification.IsSmartWallet = true return classification, nil } diff --git a/go/mechanisms/evm/exact/facilitator/eip3009_settle_erc6492_test.go b/go/mechanisms/evm/exact/facilitator/eip3009_settle_erc6492_test.go index 816ef8f3cc..ac91e4c1bf 100644 --- a/go/mechanisms/evm/exact/facilitator/eip3009_settle_erc6492_test.go +++ b/go/mechanisms/evm/exact/facilitator/eip3009_settle_erc6492_test.go @@ -32,6 +32,9 @@ type settleMockSigner struct { // writeTxHash, when set, overrides the hash returned from WriteContract so tests can // model a signer that reports success without a usable transaction hash. writeTxHash string + // writeCalls counts WriteContract invocations, so pending-settlement + // reconciliation tests can assert the fast path never re-broadcasts. + writeCalls int } func (m *settleMockSigner) GetAddresses() []string { return []string{"0xFac11"} } @@ -50,6 +53,7 @@ func (m *settleMockSigner) VerifyTypedData(ctx context.Context, address string, } func (m *settleMockSigner) WriteContract(ctx context.Context, address string, abi []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) { + m.writeCalls++ if m.writeErr != nil { return "", m.writeErr } diff --git a/go/mechanisms/evm/exact/facilitator/pending_settlement_reconciliation_test.go b/go/mechanisms/evm/exact/facilitator/pending_settlement_reconciliation_test.go new file mode 100644 index 0000000000..de7fe81d18 --- /dev/null +++ b/go/mechanisms/evm/exact/facilitator/pending_settlement_reconciliation_test.go @@ -0,0 +1,263 @@ +package facilitator + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + "github.com/x402-foundation/x402/go/v2/types" +) + +// plainPermit2Payload builds a Permit2 payment payload + requirements with no +// extensions, so pending-settlement tests exercise the base broadcast path +// (not the ERC-20-approval-gas-sponsoring branch covered separately by +// newPermit2ERC20Fixture). +func plainPermit2Payload() (types.PaymentPayload, types.PaymentRequirements, *evm.ExactPermit2Payload) { + requirements := types.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000000", + Asset: testToken, + PayTo: testPayTo, + } + permit2Payload := &evm.ExactPermit2Payload{ + Signature: "0x" + strings.Repeat("11", 65), + Permit2Authorization: evm.Permit2Authorization{ + From: testPayer, + Permitted: evm.Permit2TokenPermissions{ + Token: testToken, + Amount: "1000000", + }, + Spender: evm.X402ExactPermit2ProxyAddress, + Nonce: "1", + Deadline: fmt.Sprintf("%d", time.Now().Unix()+10000), + Witness: evm.Permit2Witness{ + To: testPayTo, + ValidAfter: "0", + }, + }, + } + payload := types.PaymentPayload{X402Version: 2, Payload: permit2Payload.ToMap(), Accepted: requirements} + return payload, requirements, permit2Payload +} + +// Exercises the PendingSettlementStore fast path wired into settleEIP3009 and +// SettlePermit2 (see eip3009.go/permit2.go): a settle attempt whose receipt +// wait fails must populate the store keyed by the payload signature; a +// subsequent settle for the identical payload must hit that entry, skip +// verify/broadcast entirely, and reconcile against the already-broadcast +// transaction. Mirrors the TS/Python pendingSettlement test suites. + +func TestSettleEIP3009_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + signer := deployedCodeSigner(nil) + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + + evmPayload := payload.Payload["signature"] + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + + _, ok, _ := store.Get(context.Background(), evmPayload.(string)) + assert.False(t, ok, "successful settlement must not leave a pending entry") +} + +func TestSettleEIP3009_PendingSettlementStore_CacheMissReceiptFailurePopulatesStore(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + signer := deployedCodeSigner(fmt.Errorf("rpc: timeout waiting for receipt")) + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + signature := payload.Payload["signature"].(string) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + wantTxHash := "0x" + strings.Repeat("ab", 32) + assertSettlementPending(t, err, wantTxHash) + + txHash, ok, _ := store.Get(context.Background(), signature) + assert.True(t, ok, "receipt-wait failure must populate the pending-settlement store") + assert.Equal(t, wantTxHash, txHash) +} + +func TestSettleEIP3009_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + signer := deployedCodeSigner(nil) // receipt wait now succeeds + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + signature := payload.Payload["signature"].(string) + priorTxHash := "0x" + strings.Repeat("ab", 32) + require.NoError(t, store.Set(context.Background(), signature, priorTxHash)) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, priorTxHash, resp.Transaction) + assert.Equal(t, 0, signer.writeCalls, "reconciliation fast path must never re-broadcast") + + _, ok, _ := store.Get(context.Background(), signature) + assert.False(t, ok, "successful reconciliation must clear the pending entry") +} + +func TestSettleEIP3009_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + signer := deployedCodeSigner(fmt.Errorf("rpc: still pending")) + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + signature := payload.Payload["signature"].(string) + priorTxHash := "0x" + strings.Repeat("ab", 32) + require.NoError(t, store.Set(context.Background(), signature, priorTxHash)) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + assertSettlementPending(t, err, priorTxHash) + assert.Equal(t, 0, signer.writeCalls, "reconciliation fast path must never re-broadcast") + + txHash, ok, _ := store.Get(context.Background(), signature) + assert.True(t, ok) + assert.Equal(t, priorTxHash, txHash) +} + +func TestSettleEIP3009_PendingSettlementStore_TerminalVerifyFailureNeverTouchesStore(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + badRequirements := requirements + badRequirements.PayTo = "0x0000000000000000000000000000000000000000" + signer := deployedCodeSigner(nil) + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + signature := payload.Payload["signature"].(string) + + _, err := scheme.Settle(context.Background(), payload, badRequirements, nil) + require.Error(t, err) + assert.Equal(t, 0, signer.writeCalls) + + _, ok, _ := store.Get(context.Background(), signature) + assert.False(t, ok) +} + +// An invalid broadcast hash means nothing usable was ever sent, so there is nothing to +// reconcile against later: the pending-settlement store must stay empty rather than being +// polluted with the unusable hash (which a later retry could otherwise try to reconcile +// against instead of correctly re-attempting the broadcast). +func TestSettleEIP3009_PendingSettlementStore_InvalidBroadcastHashNeverPopulatesStore(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + signer := deployedCodeSigner(fmt.Errorf("rpc: timeout waiting for receipt")) + signer.writeTxHash = "0xnothash" + scheme := NewExactEvmScheme(signer, &ExactEvmSchemeConfig{}) + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + signature := payload.Payload["signature"].(string) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + assertSettleErr(t, err, ErrTransactionFailed, "") + + _, ok, _ := store.Get(context.Background(), signature) + assert.False(t, ok, "an invalid broadcast hash must never be cached") +} + +func TestSettlePermit2_PendingSettlementStore_InvalidBroadcastHashNeverPopulatesStore(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(fmt.Errorf("rpc: timeout waiting for receipt")) + signer.writeTxHash = "0xnothash" + store := x402.NewInMemoryPendingSettlementStore() + + _, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, + &Permit2FacilitatorConfig{PendingSettlementStore: store}) + assertSettleErr(t, err, ErrTransactionFailed, "") + + _, ok, _ := store.Get(context.Background(), permit2Payload.Signature) + assert.False(t, ok, "an invalid broadcast hash must never be cached") +} + +func TestSettleEIP3009_PendingSettlementStore_DefaultsToFreshInMemoryStoreWhenNoneInjected(t *testing.T) { + payload, requirements := plainEIP3009Payload(t) + scheme := NewExactEvmScheme(deployedCodeSigner(nil), &ExactEvmSchemeConfig{}) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) +} + +func TestSettlePermit2_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(nil) + store := x402.NewInMemoryPendingSettlementStore() + + resp, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, + &Permit2FacilitatorConfig{PendingSettlementStore: store}) + require.NoError(t, err) + assert.True(t, resp.Success) + + _, ok, _ := store.Get(context.Background(), permit2Payload.Signature) + assert.False(t, ok) +} + +func TestSettlePermit2_PendingSettlementStore_CacheMissReceiptFailurePopulatesStore(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(fmt.Errorf("rpc: timeout waiting for receipt")) + store := x402.NewInMemoryPendingSettlementStore() + + _, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, + &Permit2FacilitatorConfig{PendingSettlementStore: store}) + wantTxHash := "0x" + strings.Repeat("ab", 32) + assertSettlementPending(t, err, wantTxHash) + + txHash, ok, _ := store.Get(context.Background(), permit2Payload.Signature) + assert.True(t, ok) + assert.Equal(t, wantTxHash, txHash) +} + +func TestSettlePermit2_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(nil) + store := x402.NewInMemoryPendingSettlementStore() + priorTxHash := "0x" + strings.Repeat("ab", 32) + require.NoError(t, store.Set(context.Background(), permit2Payload.Signature, priorTxHash)) + + resp, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, + &Permit2FacilitatorConfig{PendingSettlementStore: store}) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, priorTxHash, resp.Transaction) + assert.Equal(t, 0, signer.writeCalls, "reconciliation fast path must never re-broadcast") + + _, ok, _ := store.Get(context.Background(), permit2Payload.Signature) + assert.False(t, ok) +} + +func TestSettlePermit2_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(fmt.Errorf("rpc: still pending")) + store := x402.NewInMemoryPendingSettlementStore() + priorTxHash := "0x" + strings.Repeat("ab", 32) + require.NoError(t, store.Set(context.Background(), permit2Payload.Signature, priorTxHash)) + + _, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, + &Permit2FacilitatorConfig{PendingSettlementStore: store}) + assertSettlementPending(t, err, priorTxHash) + assert.Equal(t, 0, signer.writeCalls) + + txHash, ok, _ := store.Get(context.Background(), permit2Payload.Signature) + assert.True(t, ok) + assert.Equal(t, priorTxHash, txHash) +} + +func TestSettlePermit2_PendingSettlementStore_DefaultsToFreshInMemoryStoreWhenNoneInjected(t *testing.T) { + payload, requirements, permit2Payload := plainPermit2Payload() + signer := deployedCodeSigner(nil) + + resp, err := SettlePermit2(context.Background(), signer, payload, requirements, permit2Payload, nil, nil) + require.NoError(t, err) + assert.True(t, resp.Success) +} diff --git a/go/mechanisms/evm/exact/facilitator/permit2.go b/go/mechanisms/evm/exact/facilitator/permit2.go index 340c372b3d..078d69b43c 100644 --- a/go/mechanisms/evm/exact/facilitator/permit2.go +++ b/go/mechanisms/evm/exact/facilitator/permit2.go @@ -21,6 +21,12 @@ type VerifyPermit2Options struct { Simulate *bool } +// assetContractCheck carries evm.ValidateAssetIsContract's result out of a goroutine. +type assetContractCheck struct { + reason string + err error +} + func (o *VerifyPermit2Options) shouldSimulate() bool { if o == nil || o.Simulate == nil { return true @@ -57,11 +63,12 @@ func VerifyPermit2( tokenAddress := evm.NormalizeAddress(requirements.Asset) - if errReason, err := evm.ValidateAssetIsContract(ctx, signer, requirements.Asset); err != nil { - return nil, fmt.Errorf("asset contract check failed: %w", err) - } else if errReason != "" { - return nil, x402.NewVerifyError(errReason, payer, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) - } + // Run the asset-contract check concurrently with the signature check below. + assetCheckCh := make(chan assetContractCheck, 1) + go func() { + reason, err := evm.ValidateAssetIsContract(ctx, signer, requirements.Asset) + assetCheckCh <- assetContractCheck{reason: reason, err: err} + }() // Verify spender is x402ExactPermit2Proxy if !strings.EqualFold(permit2Payload.Permit2Authorization.Spender, evm.X402ExactPermit2ProxyAddress) { @@ -116,12 +123,25 @@ func VerifyPermit2( return nil, x402.NewVerifyError(ErrInvalidSignatureFormat, payer, err.Error()) } - sigValid, sigErr := verifyPermit2Signature(ctx, signer, permit2Payload.Permit2Authorization, signatureBytes, chainID) + sigValid, sigData, sigErr := verifyPermit2Signature(ctx, signer, permit2Payload.Permit2Authorization, signatureBytes, chainID) + + assetResult := <-assetCheckCh + if assetResult.err != nil { + return nil, fmt.Errorf("asset contract check failed: %w", assetResult.err) + } + if assetResult.reason != "" { + return nil, x402.NewVerifyError(assetResult.reason, payer, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) + } + if sigErr != nil || !sigValid { - // Check if payer is a deployed smart contract - // ERC-1271 signatures may not be verifiable by all signer implementations - code, codeErr := signer.GetCode(ctx, payer) - if codeErr != nil || len(code) == 0 { + // Check if payer is a deployed smart contract. + deployed := false + if sigData != nil { + deployed = sigData.CodeDeployed + } else if code, codeErr := signer.GetCode(ctx, payer); codeErr == nil { + deployed = len(code) > 0 + } + if !deployed { return nil, x402.NewVerifyError(ErrPermit2InvalidSignature, payer, "invalid signature") } // Deployed smart contract: fall through to simulation @@ -206,6 +226,42 @@ type Permit2FacilitatorConfig struct { // SimulateInSettle re-runs simulation during settle // When false (default), the settle path skips simulation since verify already ran it SimulateInSettle bool + // PendingSettlementStore lets a retried settle for the same payload + // reconcile against an already-broadcast transaction instead of + // re-verifying and re-broadcasting. A nil value falls back to a fresh + // in-memory store (equivalent to no cross-call sharing). + PendingSettlementStore x402.PendingSettlementStore +} + +// ResolvePermit2ReceiptWaitSigner returns the signer that should wait for the +// settlement receipt: the ERC-20-approval-gas-sponsoring extension's signer +// when that extension provided the transaction (it may have broadcast via a +// different account), otherwise the facilitator's own signer. Depends only on +// payload.Extensions/facilCtx, so it can be resolved before verify runs (the +// pending-settlement fast path skips verify entirely). Exported so the upto +// EVM facilitator (which shares the same ERC-20-approval-gas-sponsoring +// extension) can reuse the same resolution logic. +func ResolvePermit2ReceiptWaitSigner( + signer evm.FacilitatorEvmSigner, + facilCtx *x402.FacilitatorContext, + extensions map[string]interface{}, + network string, +) evm.FacilitatorEvmSigner { + if facilCtx == nil { + return signer + } + erc20Info, _ := erc20approvalgassponsor.ExtractInfo(extensions) + if erc20Info == nil { + return signer + } + ext, ok := facilCtx.GetExtension(erc20approvalgassponsor.ERC20ApprovalGasSponsoring.Key()).(*erc20approvalgassponsor.Erc20ApprovalFacilitatorExtension) + if !ok || ext == nil { + return signer + } + if extensionSigner := ext.ResolveSigner(network); extensionSigner != nil { + return extensionSigner + } + return signer } // SettlePermit2 settles a Permit2 payment by calling x402ExactPermit2Proxy.settle(). @@ -222,12 +278,31 @@ func SettlePermit2( payer := permit2Payload.Permit2Authorization.From simulate := false + var store x402.PendingSettlementStore if config != nil { simulate = config.SimulateInSettle + store = config.PendingSettlementStore + } + if store == nil { + store = x402.NewInMemoryPendingSettlementStore() } - verifyResp, err := VerifyPermit2(ctx, signer, payload, requirements, permit2Payload, facilCtx, &VerifyPermit2Options{Simulate: &simulate}) - if err != nil { + // Fast path: a prior settle attempt for this exact payload already + // broadcast a transaction whose receipt wait failed (settlement_pending). + // Reconcile against it instead of re-verifying/re-broadcasting. + if permit2Payload.Signature != "" { + if txHash, ok, _ := store.Get(ctx, permit2Payload.Signature); ok { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + _ = store.Delete(ctx, permit2Payload.Signature) + receiptWaitSigner := ResolvePermit2ReceiptWaitSigner(signer, facilCtx, payload.Extensions, payload.Accepted.Network) + return AwaitPermit2Settlement(ctx, store, receiptWaitSigner, permit2Payload.Signature, txHash, payer, network, ErrTransactionFailed, "") + } + } + + if _, err := VerifyPermit2(ctx, signer, payload, requirements, permit2Payload, facilCtx, &VerifyPermit2Options{Simulate: &simulate}); err != nil { ve := &x402.VerifyError{} if errors.As(err, &ve) { return nil, x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) @@ -357,25 +432,35 @@ func SettlePermit2( } // Wait for transaction confirmation - receiptWaitSigner := signer - if erc20Info != nil && facilCtx != nil { - if ext, ok := facilCtx.GetExtension(erc20approvalgassponsor.ERC20ApprovalGasSponsoring.Key()).(*erc20approvalgassponsor.Erc20ApprovalFacilitatorExtension); ok && ext != nil { - if extensionSigner := ext.ResolveSigner(payload.Accepted.Network); extensionSigner != nil { - receiptWaitSigner = extensionSigner - } - } - } - if _, err := evm.WaitForSettleReceipt(ctx, receiptWaitSigner, txHash, payer, network, - ErrTransactionFailed, ErrTransactionFailed); err != nil { + receiptWaitSigner := ResolvePermit2ReceiptWaitSigner(signer, facilCtx, payload.Extensions, payload.Accepted.Network) + return AwaitPermit2Settlement(ctx, store, receiptWaitSigner, permit2Payload.Signature, txHash, payer, network, ErrTransactionFailed, "") +} + +// AwaitPermit2Settlement waits for the broadcast transaction's receipt (with +// PendingSettlementStore bookkeeping) and builds the settle response, shared +// by both the pending-settlement reconciliation fast path and the normal +// broadcast path above, and reused by the upto Permit2 scheme (which shares +// this settlement shape modulo a variable amount and error reason). pendingKey +// may be "" (no signature available), which disables the bookkeeping while +// still waiting for the receipt. amount may be "" for schemes without a +// variable settlement amount (e.g. exact) — SettleResponse.Amount has +// `omitempty`, so this is a no-op. +func AwaitPermit2Settlement( + ctx context.Context, + store x402.PendingSettlementStore, + receiptWaitSigner evm.FacilitatorEvmSigner, + pendingKey string, + txHash string, + payer string, + network x402.Network, + failedReason string, + amount string, +) (*x402.SettleResponse, error) { + if _, err := evm.WaitForSettleReceiptWithPendingStore(ctx, store, pendingKey, receiptWaitSigner, txHash, payer, network, + failedReason, failedReason); err != nil { return nil, err } - - return &x402.SettleResponse{ - Success: true, - Transaction: txHash, - Network: network, - Payer: verifyResp.Payer, - }, nil + return &x402.SettleResponse{Success: true, Transaction: txHash, Network: network, Payer: payer, Amount: amount}, nil } // verifyPermit2Signature verifies the Permit2 EIP-712 signature. @@ -385,18 +470,17 @@ func verifyPermit2Signature( authorization evm.Permit2Authorization, signature []byte, chainID *big.Int, -) (bool, error) { +) (bool, *evm.ERC6492SignatureData, error) { hash, err := evm.HashPermit2Authorization(authorization, chainID) if err != nil { - return false, err + return false, nil, err } var hash32 [32]byte copy(hash32[:], hash) // Use universal verification (supports EOA and EIP-1271) - valid, _, err := evm.VerifyUniversalSignature(ctx, signer, authorization.From, hash32, signature, true) - return valid, err + return evm.VerifyUniversalSignature(ctx, signer, authorization.From, hash32, signature, true) } var validateEip2612PermitForPayment = evm.ValidateEip2612PermitForPayment diff --git a/go/mechanisms/evm/exact/facilitator/scheme.go b/go/mechanisms/evm/exact/facilitator/scheme.go index 8535335eaa..e69cc3c1bc 100644 --- a/go/mechanisms/evm/exact/facilitator/scheme.go +++ b/go/mechanisms/evm/exact/facilitator/scheme.go @@ -24,8 +24,9 @@ type ExactEvmSchemeConfig struct { // ExactEvmScheme implements the SchemeNetworkFacilitator interface for EVM exact payments (V2) type ExactEvmScheme struct { - signer evm.FacilitatorEvmSigner - config ExactEvmSchemeConfig + signer evm.FacilitatorEvmSigner + config ExactEvmSchemeConfig + pendingStore x402.PendingSettlementStore } // NewExactEvmScheme creates a new ExactEvmScheme @@ -43,8 +44,21 @@ func NewExactEvmScheme(signer evm.FacilitatorEvmSigner, config *ExactEvmSchemeCo cfg = *config } return &ExactEvmScheme{ - signer: signer, - config: cfg, + signer: signer, + config: cfg, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + } +} + +// SetPendingSettlementStore overrides the default in-memory +// PendingSettlementStore. Used by multi-instance facilitators (e.g. +// cdp-facilitator) to inject a shared, network-backed implementation (e.g. +// Redis) so a settle retry landing on a different replica still reconciles +// against the transaction the first replica broadcast. A nil store is a +// no-op. +func (f *ExactEvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store } } @@ -108,7 +122,8 @@ func (f *ExactEvmScheme) Settle( return nil, x402.NewSettleError(ErrInvalidPayload, "", network, "", fmt.Sprintf("failed to parse Permit2 payload: %s", err.Error())) } return SettlePermit2(ctx, f.signer, payload, requirements, permit2Payload, fctx, &Permit2FacilitatorConfig{ - SimulateInSettle: f.config.SimulateInSettle, + SimulateInSettle: f.config.SimulateInSettle, + PendingSettlementStore: f.pendingStore, }) } diff --git a/go/mechanisms/evm/exact/server/scheme.go b/go/mechanisms/evm/exact/server/scheme.go index 7856dbe34d..209184530b 100644 --- a/go/mechanisms/evm/exact/server/scheme.go +++ b/go/mechanisms/evm/exact/server/scheme.go @@ -35,13 +35,13 @@ func (s *ExactEvmScheme) DefaultAssetTransferMethod() string { // PaymentFlows returns ATM-keyed payment flow support for exact EVM. func (s *ExactEvmScheme) PaymentFlows() map[string]x402.PaymentFlowConfig { - auth := x402.PaymentFlowConfig{ - Supported: []x402.PaymentFlowName{x402.PaymentFlowAuthorization}, + authAndUpfront := x402.PaymentFlowConfig{ + Supported: []x402.PaymentFlowName{x402.PaymentFlowAuthorization, x402.PaymentFlowUpfront}, Default: x402.PaymentFlowAuthorization, } return map[string]x402.PaymentFlowConfig{ - string(evm.AssetTransferMethodEIP3009): auth, - string(evm.AssetTransferMethodPermit2): auth, + string(evm.AssetTransferMethodEIP3009): authAndUpfront, + string(evm.AssetTransferMethodPermit2): authAndUpfront, } } diff --git a/go/mechanisms/evm/exact/server/scheme_test.go b/go/mechanisms/evm/exact/server/scheme_test.go new file mode 100644 index 0000000000..930c7e3fb5 --- /dev/null +++ b/go/mechanisms/evm/exact/server/scheme_test.go @@ -0,0 +1,24 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +func TestPaymentFlowsDeclareAuthorizationAndUpfront(t *testing.T) { + scheme := NewExactEvmScheme() + flows := scheme.PaymentFlows() + + for _, atm := range []string{ + string(evm.AssetTransferMethodEIP3009), + string(evm.AssetTransferMethodPermit2), + } { + require.Contains(t, flows, atm) + assert.Equal(t, []x402.PaymentFlowName{x402.PaymentFlowAuthorization, x402.PaymentFlowUpfront}, flows[atm].Supported) + assert.Equal(t, x402.PaymentFlowAuthorization, flows[atm].Default) + } +} diff --git a/go/mechanisms/evm/types.go b/go/mechanisms/evm/types.go index 1b1ed1ae59..229d9844a5 100644 --- a/go/mechanisms/evm/types.go +++ b/go/mechanisms/evm/types.go @@ -533,4 +533,8 @@ type ERC6492SignatureData struct { Factory [20]byte // CREATE2 factory address (zero address if not ERC-6492) FactoryCalldata []byte // Calldata to deploy the wallet (empty if not ERC-6492) InnerSignature []byte // The actual signature (EIP-1271 or EOA) + + // CodeDeployed reports whether the signer had bytecode, as determined by + // VerifyUniversalSignature. Zero value elsewhere. + CodeDeployed bool } diff --git a/go/mechanisms/evm/upto/facilitator/permit2.go b/go/mechanisms/evm/upto/facilitator/permit2.go index 837b683a09..f3c4eac2ac 100644 --- a/go/mechanisms/evm/upto/facilitator/permit2.go +++ b/go/mechanisms/evm/upto/facilitator/permit2.go @@ -16,6 +16,12 @@ import ( "github.com/x402-foundation/x402/go/v2/types" ) +// assetContractCheck carries evm.ValidateAssetIsContract's result out of a goroutine. +type assetContractCheck struct { + reason string + err error +} + // VerifyUptoPermit2 verifies an upto Permit2 payment payload against the given requirements. // simulate controls whether to run an eth_call simulation as part of verification. func VerifyUptoPermit2( @@ -44,11 +50,12 @@ func VerifyUptoPermit2( tokenAddress := evm.NormalizeAddress(requirements.Asset) - if errReason, err := evm.ValidateAssetIsContract(ctx, signer, requirements.Asset); err != nil { - return nil, fmt.Errorf("asset contract check failed: %w", err) - } else if errReason != "" { - return nil, x402.NewVerifyError(errReason, payer, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) - } + // Run the asset-contract check concurrently with the signature check below. + assetCheckCh := make(chan assetContractCheck, 1) + go func() { + reason, err := evm.ValidateAssetIsContract(ctx, signer, requirements.Asset) + assetCheckCh <- assetContractCheck{reason: reason, err: err} + }() if !strings.EqualFold(permit2Payload.Permit2Authorization.Spender, evm.X402UptoPermit2ProxyAddress) { return nil, x402.NewVerifyError(ErrPermit2InvalidSpender, payer, "invalid spender") @@ -110,10 +117,24 @@ func VerifyUptoPermit2( return nil, x402.NewVerifyError(ErrInvalidSignatureFormat, payer, err.Error()) } - sigValid, sigErr := verifyUptoPermit2Signature(ctx, signer, permit2Payload.Permit2Authorization, signatureBytes, chainID) + sigValid, sigData, sigErr := verifyUptoPermit2Signature(ctx, signer, permit2Payload.Permit2Authorization, signatureBytes, chainID) + + assetResult := <-assetCheckCh + if assetResult.err != nil { + return nil, fmt.Errorf("asset contract check failed: %w", assetResult.err) + } + if assetResult.reason != "" { + return nil, x402.NewVerifyError(assetResult.reason, payer, fmt.Sprintf("asset %s is not a deployed contract", requirements.Asset)) + } + if sigErr != nil || !sigValid { - code, codeErr := signer.GetCode(ctx, payer) - if codeErr != nil || len(code) == 0 { + deployed := false + if sigData != nil { + deployed = sigData.CodeDeployed + } else if code, codeErr := signer.GetCode(ctx, payer); codeErr == nil { + deployed = len(code) > 0 + } + if !deployed { return nil, x402.NewVerifyError(ErrPermit2InvalidSignature, payer, "invalid signature") } } @@ -190,6 +211,9 @@ func VerifyUptoPermit2( // SettleUptoPermit2 settles an upto Permit2 payment by calling x402UptoPermit2Proxy.settle(). // simulateInSettle controls whether to run an eth_call simulation as part of pre-settle verification. +// store is consulted first (keyed on permit2Payload.Signature) to reconcile a +// previously-broadcast-but-unconfirmed transaction from a prior settlement_pending +// response, instead of re-broadcasting. A nil store disables this fast path. func SettleUptoPermit2( ctx context.Context, signer evm.FacilitatorEvmSigner, @@ -198,15 +222,33 @@ func SettleUptoPermit2( permit2Payload *evm.UptoPermit2Payload, facilCtx *x402.FacilitatorContext, simulateInSettle bool, + store x402.PendingSettlementStore, ) (*x402.SettleResponse, error) { network := x402.Network(payload.Accepted.Network) payer := permit2Payload.Permit2Authorization.From + // Parsed up front: requirements.Amount is identical on a resource-server + // retry (byte-identical payload/requirements), so the fast path below can + // safely reuse it to report the settled Amount without re-verifying. settlementAmount, ok := new(big.Int).SetString(requirements.Amount, 10) if !ok { return nil, x402.NewSettleError(ErrUptoInvalidPayload, payer, network, "", "invalid settlement amount") } + if store != nil && permit2Payload.Signature != "" { + if txHash, ok, _ := store.Get(ctx, permit2Payload.Signature); ok { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + _ = store.Delete(ctx, permit2Payload.Signature) + receiptWaitSigner := exactfacilitator.ResolvePermit2ReceiptWaitSigner(signer, facilCtx, payload.Extensions, payload.Accepted.Network) + return exactfacilitator.AwaitPermit2Settlement( + ctx, store, receiptWaitSigner, permit2Payload.Signature, txHash, payer, network, ErrUptoTransactionFailed, settlementAmount.String(), + ) + } + } + // Re-verify with permitted.amount as requirements.Amount (the authorized max) verifyRequirements := requirements verifyRequirements.Amount = permit2Payload.Permit2Authorization.Permitted.Amount @@ -356,45 +398,29 @@ func SettleUptoPermit2( return nil, x402.NewSettleError(errorReason, payer, network, "", err.Error()) } - receiptWaitSigner := signer - if erc20Info != nil && facilCtx != nil { - if ext, ok := facilCtx.GetExtension(erc20approvalgassponsor.ERC20ApprovalGasSponsoring.Key()).(*erc20approvalgassponsor.Erc20ApprovalFacilitatorExtension); ok && ext != nil { - if extensionSigner := ext.ResolveSigner(payload.Accepted.Network); extensionSigner != nil { - receiptWaitSigner = extensionSigner - } - } - } - if _, err := evm.WaitForSettleReceipt(ctx, receiptWaitSigner, txHash, payer, network, - ErrUptoTransactionFailed, ErrUptoTransactionFailed); err != nil { - return nil, err - } - - return &x402.SettleResponse{ - Success: true, - Transaction: txHash, - Network: network, - Payer: verifyResp.Payer, - Amount: settlementAmount.String(), - }, nil + receiptWaitSigner := exactfacilitator.ResolvePermit2ReceiptWaitSigner(signer, facilCtx, payload.Extensions, payload.Accepted.Network) + return exactfacilitator.AwaitPermit2Settlement( + ctx, store, receiptWaitSigner, permit2Payload.Signature, txHash, verifyResp.Payer, network, ErrUptoTransactionFailed, settlementAmount.String(), + ) } +// verifyUptoPermit2Signature verifies the upto Permit2 EIP-712 signature. func verifyUptoPermit2Signature( ctx context.Context, signer evm.FacilitatorEvmSigner, authorization evm.UptoPermit2Authorization, signature []byte, chainID *big.Int, -) (bool, error) { +) (bool, *evm.ERC6492SignatureData, error) { hash, err := evm.HashUptoPermit2Authorization(authorization, chainID) if err != nil { - return false, err + return false, nil, err } var hash32 [32]byte copy(hash32[:], hash) - valid, _, err := evm.VerifyUniversalSignature(ctx, signer, authorization.From, hash32, signature, true) - return valid, err + return evm.VerifyUniversalSignature(ctx, signer, authorization.From, hash32, signature, true) } var validateEip2612PermitForPayment = evm.ValidateEip2612PermitForPayment diff --git a/go/mechanisms/evm/upto/facilitator/permit2_test.go b/go/mechanisms/evm/upto/facilitator/permit2_test.go index d298cd679a..5f8f9aac62 100644 --- a/go/mechanisms/evm/upto/facilitator/permit2_test.go +++ b/go/mechanisms/evm/upto/facilitator/permit2_test.go @@ -31,6 +31,9 @@ type mockFacilitatorSigner struct { verifyError error receiptResult *evm.TransactionReceipt receiptError error + // writeContractCalls counts WriteContract invocations, so pending-settlement + // reconciliation tests can assert the fast path never re-broadcasts. + writeContractCalls int } func newMockSigner(addresses ...string) *mockFacilitatorSigner { @@ -60,6 +63,7 @@ func (m *mockFacilitatorSigner) VerifyTypedData(ctx context.Context, address str } func (m *mockFacilitatorSigner) WriteContract(ctx context.Context, address string, abi []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) { + m.writeContractCalls++ if m.writeContractError != nil { return "", m.writeContractError } @@ -451,7 +455,7 @@ func TestSettleUptoPermit2_ZeroSettlement(t *testing.T) { req := buildValidRequirements() req.Amount = "0" // settle zero — no on-chain tx - resp, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false) + resp, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -474,7 +478,7 @@ func TestSettleUptoPermit2_ExceedsPermittedAmount(t *testing.T) { req := buildValidRequirements() req.Amount = "99999" // more than permitted "1000" - _, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false, nil) assertSettleError(t, err, ErrUptoSettlementExceedsAmount) } @@ -487,6 +491,7 @@ func TestSettleUptoPermit2_FullAmount(t *testing.T) { buildValidUptoPayload(testFacilitatorAddr), nil, false, + nil, ) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -504,7 +509,7 @@ func TestSettleUptoPermit2_PartialAmount(t *testing.T) { req := buildValidRequirements() req.Amount = "500" // 500 of 1000 permitted - resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), req, buildValidUptoPayload(testFacilitatorAddr), nil, false) + resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), req, buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -521,7 +526,7 @@ func TestSettleUptoPermit2_InvalidSettlementAmount(t *testing.T) { req := buildValidRequirements() req.Amount = "not-a-number" - _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), req, buildValidUptoPayload(testFacilitatorAddr), nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), req, buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err == nil { t.Fatal("expected error on invalid settlement amount") } @@ -531,7 +536,7 @@ func TestSettleUptoPermit2_WriteContractFails(t *testing.T) { signer := newMockSigner() signer.writeContractError = errors.New("out of gas") - _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err == nil { t.Fatal("expected error on WriteContract failure") } @@ -541,7 +546,7 @@ func TestSettleUptoPermit2_ReceiptStatusFailed(t *testing.T) { signer := newMockSigner() signer.receiptResult = &evm.TransactionReceipt{Status: evm.TxStatusFailed, TxHash: "0xfail"} - _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) assertSettleError(t, err, ErrUptoTransactionFailed) } @@ -549,7 +554,7 @@ func TestSettleUptoPermit2_ReceiptError(t *testing.T) { signer := newMockSigner() signer.receiptError = errors.New("timeout") - resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false) + resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err == nil { t.Fatalf("expected settlement_pending error, got success: %+v", resp) } @@ -564,6 +569,115 @@ func TestSettleUptoPermit2_ReceiptError(t *testing.T) { } } +// ─── SettleUptoPermit2 — PendingSettlementStore fast path ─────────────────── +// +// Mirrors the TS/Python upto pending-settlement test suites: a receipt-wait +// failure must populate the injected store keyed by the Permit2 signature, +// and a subsequent settle for the identical payload must hit that entry, +// skip verify/broadcast entirely, and reconcile against the already- +// broadcast transaction — using the SAME settlement-override amount, since +// the resource server's single retry resends the identical +// payload/requirements bytes (see settleWithPendingRetry in go/server.go). + +func TestSettleUptoPermit2_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + signer := newMockSigner() + store := x402.NewInMemoryPendingSettlementStore() + p := buildValidUptoPayload(testFacilitatorAddr) + + resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), p, nil, false, store) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resp.Success { + t.Fatalf("expected success, got %s", resp.ErrorReason) + } + + if _, ok, _ := store.Get(context.Background(), p.Signature); ok { + t.Error("successful settlement must not leave a pending entry") + } +} + +func TestSettleUptoPermit2_PendingSettlementStore_CacheMissReceiptFailurePopulatesStore(t *testing.T) { + signer := newMockSigner() + signer.receiptError = errors.New("timeout") + store := x402.NewInMemoryPendingSettlementStore() + p := buildValidUptoPayload(testFacilitatorAddr) + + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), p, nil, false, store) + assertSettleError(t, err, ErrSettlementPending) + + txHash, ok, _ := store.Get(context.Background(), p.Signature) + if !ok { + t.Fatal("receipt-wait failure must populate the pending-settlement store") + } + if txHash != signer.writeContractTx { + t.Errorf("expected stored tx hash %q, got %q", signer.writeContractTx, txHash) + } +} + +func TestSettleUptoPermit2_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + signer := newMockSigner() // receipt wait now succeeds + store := x402.NewInMemoryPendingSettlementStore() + p := buildValidUptoPayload(testFacilitatorAddr) + priorTxHash := "0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcd" + if err := store.Set(context.Background(), p.Signature, priorTxHash); err != nil { + t.Fatalf("store.Set: %v", err) + } + + resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), p, nil, false, store) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resp.Success || resp.Transaction != priorTxHash { + t.Fatalf("expected reconciled success with tx %q, got success=%v tx=%q reason=%s", + priorTxHash, resp.Success, resp.Transaction, resp.ErrorReason) + } + if resp.Amount != testAmount { + t.Errorf("reconciled response must report the settled Amount like the normal broadcast path does, got %q want %q", + resp.Amount, testAmount) + } + if signer.writeContractCalls != 0 { + t.Errorf("reconciliation fast path must never re-broadcast, got %d WriteContract calls", signer.writeContractCalls) + } + + if _, ok, _ := store.Get(context.Background(), p.Signature); ok { + t.Error("successful reconciliation must clear the pending entry") + } +} + +func TestSettleUptoPermit2_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + signer := newMockSigner() + signer.receiptError = errors.New("still pending") + store := x402.NewInMemoryPendingSettlementStore() + p := buildValidUptoPayload(testFacilitatorAddr) + priorTxHash := "0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcd" + if err := store.Set(context.Background(), p.Signature, priorTxHash); err != nil { + t.Fatalf("store.Set: %v", err) + } + + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), p, nil, false, store) + assertSettleError(t, err, ErrSettlementPending) + if signer.writeContractCalls != 0 { + t.Errorf("reconciliation fast path must never re-broadcast, got %d WriteContract calls", signer.writeContractCalls) + } + + txHash, ok, _ := store.Get(context.Background(), p.Signature) + if !ok || txHash != priorTxHash { + t.Errorf("expected pending entry to persist with tx %q, got ok=%v tx=%q", priorTxHash, ok, txHash) + } +} + +func TestSettleUptoPermit2_PendingSettlementStore_NilStoreDisablesFastPath(t *testing.T) { + signer := newMockSigner() + resp, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resp.Success { + t.Errorf("expected success, got %s", resp.ErrorReason) + } +} + // mockErc20ApprovalSigner wraps mockFacilitatorSigner with SendTransactions to satisfy // erc20approvalgassponsor.Erc20ApprovalGasSponsoringSigner for the ERC-20 approval branch. type mockErc20ApprovalSigner struct { @@ -607,7 +721,7 @@ func erc20ApprovalSettleCtx(sendTxHashes []string, receiptErr error) (*x402.Faci func TestSettleUptoPermit2_ERC20ApprovalIncompleteHashesFailTerminally(t *testing.T) { facilCtx, payload := erc20ApprovalSettleCtx([]string{"0xapproval"}, nil) - _, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false) + _, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false, nil) if err == nil { t.Fatal("expected error when extension signer returns incomplete transaction hashes") } @@ -629,7 +743,7 @@ func TestSettleUptoPermit2_InvalidBroadcastHashIsTerminal(t *testing.T) { signer.writeContractTx = "0xnothash" signer.receiptError = errors.New("timeout") - _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err == nil { t.Fatal("expected error when the signer returns an invalid transaction hash") } @@ -648,7 +762,7 @@ func TestSettleUptoPermit2_ERC20ApprovalAtomicBundleSingleHashSucceeds(t *testin bundleHash := "0x" + strings.Repeat("ef", 32) facilCtx, payload := erc20ApprovalSettleCtx([]string{bundleHash}, nil) - resp, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false) + resp, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false, nil) if err != nil { t.Fatalf("expected success with a single bundled hash, got error: %v", err) } @@ -664,7 +778,7 @@ func TestSettleUptoPermit2_ERC20ApprovalExtensionReceiptWaitFailureReturnsSettle errors.New("rpc: timeout waiting for receipt"), ) - resp, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false) + resp, err := SettleUptoPermit2(context.Background(), newMockSigner(), payload, buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), facilCtx, false, nil) if err == nil { t.Fatalf("expected settlement_pending error, got success: %+v", resp) } @@ -684,7 +798,7 @@ func TestSettleUptoPermit2_VerifyFails_EOAPayer(t *testing.T) { signer := newMockSigner() signer.getCodeResult = []byte{} // EOA - _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false) + _, err := SettleUptoPermit2(context.Background(), signer, buildValidPayload(testFacilitatorAddr), buildValidRequirements(), buildValidUptoPayload(testFacilitatorAddr), nil, false, nil) if err == nil { t.Fatal("expected error when verify fails during settle") } @@ -713,7 +827,7 @@ func TestSettleUptoPermit2_WithEIP2612_ZeroSettlement(t *testing.T) { req := buildValidRequirements() req.Amount = "0" - resp, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false) + resp, err := SettleUptoPermit2(context.Background(), signer, payload, req, p, nil, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/go/mechanisms/evm/upto/facilitator/scheme.go b/go/mechanisms/evm/upto/facilitator/scheme.go index d671dea9d1..4223e4abeb 100644 --- a/go/mechanisms/evm/upto/facilitator/scheme.go +++ b/go/mechanisms/evm/upto/facilitator/scheme.go @@ -17,8 +17,9 @@ type UptoEvmSchemeConfig struct { // UptoEvmScheme implements the SchemeNetworkFacilitator interface for EVM upto payments (V2). // Only supports Permit2 (no EIP-3009 path). type UptoEvmScheme struct { - signer evm.FacilitatorEvmSigner - config UptoEvmSchemeConfig + signer evm.FacilitatorEvmSigner + config UptoEvmSchemeConfig + pendingStore x402.PendingSettlementStore } func NewUptoEvmScheme(signer evm.FacilitatorEvmSigner, config *UptoEvmSchemeConfig) *UptoEvmScheme { @@ -27,8 +28,18 @@ func NewUptoEvmScheme(signer evm.FacilitatorEvmSigner, config *UptoEvmSchemeConf cfg = *config } return &UptoEvmScheme{ - signer: signer, - config: cfg, + signer: signer, + config: cfg, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + } +} + +// SetPendingSettlementStore overrides the default in-memory +// PendingSettlementStore. See ExactEvmScheme.SetPendingSettlementStore for +// rationale. A nil store is a no-op. +func (f *UptoEvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store } } @@ -95,5 +106,5 @@ func (f *UptoEvmScheme) Settle( return nil, x402.NewSettleError(ErrUptoInvalidPayload, "", network, "", fmt.Sprintf("failed to parse upto Permit2 payload: %s", err.Error())) } - return SettleUptoPermit2(ctx, f.signer, payload, requirements, permit2Payload, fctx, f.config.SimulateInSettle) + return SettleUptoPermit2(ctx, f.signer, payload, requirements, permit2Payload, fctx, f.config.SimulateInSettle, f.pendingStore) } diff --git a/go/mechanisms/evm/utils.go b/go/mechanisms/evm/utils.go index 5e01611e36..b32a0a2bb4 100644 --- a/go/mechanisms/evm/utils.go +++ b/go/mechanisms/evm/utils.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "fmt" "math/big" "strings" @@ -97,6 +98,68 @@ func WaitForSettleReceipt( return receipt, nil } +// WaitForSettleReceiptWithPendingStore wraps WaitForSettleReceipt with the +// PendingSettlementStore bookkeeping shared by every EVM settle path that +// supports settlement_pending reconciliation: on a wait failure, record +// txHash under pendingKey so a subsequent settle attempt for the same +// payload can reconcile against it instead of re-broadcasting; on success, +// clear any stale entry. store may be nil and pendingKey may be "" — either +// disables the bookkeeping while still waiting for the receipt. +// +// Mirrors the TypeScript SDK's withPendingSettlementStore and the Python +// SDK's wait_for_receipt_and_build_response. Used directly by Permit2 +// (exact + upto) and batch-settlement deposit; EIP-3009 wraps this to add a +// post-receipt Transfer-event check (see awaitEIP3009Settlement in +// exact/facilitator/eip3009.go), since a confirmed-but-mismatched receipt +// must clear the pending entry (terminal), not set it. +func WaitForSettleReceiptWithPendingStore( + ctx context.Context, + store x402.PendingSettlementStore, + pendingKey string, + signer receiptWaiter, + txHash string, + payer string, + network x402.Network, + invalidHashReason string, + revertedReason string, +) (*TransactionReceipt, error) { + // An invalid hash means nothing usable was ever broadcast: clear any stale + // entry instead of caching the garbage hash. Checked here (rather than + // relying on WaitForSettleReceipt) to distinguish this from a genuine + // wait failure below. + if !IsValidTxHash(txHash) { + if store != nil && pendingKey != "" { + _ = store.Delete(ctx, pendingKey) + } + return nil, InvalidBroadcastHashError(invalidHashReason, payer, network, txHash) + } + + receipt, err := WaitForSettleReceipt(ctx, signer, txHash, payer, network, invalidHashReason, revertedReason) + if err != nil { + // Only a receipt-wait failure (settlement_pending) is safe to cache for + // reconciliation. A reverted receipt is terminal and must not be cached — + // otherwise it lingers as a false "pending" entry until TTL expiry. + var se *x402.SettleError + if store != nil && pendingKey != "" && errors.As(err, &se) && se.ErrorReason == ErrSettlementPending { + if setErr := store.Set(ctx, pendingKey, txHash); setErr != nil { + // Can't guarantee a later retry will find this to reconcile + // against — a blind retry could re-verify/re-broadcast and + // double-send. Downgrade to the terminal reason, preserving the + // transaction hash for manual reconciliation. + return nil, x402.NewSettleError(revertedReason, payer, network, txHash, + fmt.Sprintf("settlement_pending, but failed to persist for retry: %s", setErr.Error())) + } + } + return nil, err + } + if store != nil && pendingKey != "" { + // Best-effort: a failed delete only leaves a stale entry that expires + // via TTL. The receipt is already confirmed and must still be returned. + _ = store.Delete(ctx, pendingKey) + } + return receipt, nil +} + // GetEvmChainId returns the chain ID for a given CAIP-2 network identifier (eip155:CHAIN_ID). func GetEvmChainId(network string) (*big.Int, error) { if strings.HasPrefix(network, "eip155:") { diff --git a/go/mechanisms/evm/verify_universal.go b/go/mechanisms/evm/verify_universal.go index 113ad81e76..35ceeb1471 100644 --- a/go/mechanisms/evm/verify_universal.go +++ b/go/mechanisms/evm/verify_universal.go @@ -62,6 +62,7 @@ func VerifyUniversalSignature( } isDeployed := len(code) > 0 + sigData.CodeDeployed = isDeployed zeroFactory := [20]byte{} // Step 5: Handle undeployed address diff --git a/go/mechanisms/svm/constants.go b/go/mechanisms/svm/constants.go index 08af31c19b..95444b7b60 100644 --- a/go/mechanisms/svm/constants.go +++ b/go/mechanisms/svm/constants.go @@ -30,12 +30,9 @@ const ( // MaxMemoBytes is the maximum byte length for seller-defined memo data (extra.memo) MaxMemoBytes = 256 - // LighthouseProgramAddress is the Phantom/Solflare Lighthouse program address - // Phantom and Solflare wallets inject Lighthouse instructions for user protection on mainnet transactions. - // - Phantom adds 1 Lighthouse instruction (4th instruction) - // - Solflare adds 2 Lighthouse instructions (4th and 5th instructions) - // We allow these as optional instructions to support these wallets. - // See: https://github.com/x402-foundation/x402/issues/828 + // LighthouseProgramAddress is the wallet-protection program some Solana wallets + // inject into signed transactions. Path 1 accepts up to 3 Lighthouse instructions + // plus an optional memo, in any order after ComputeLimit + ComputePrice + TransferChecked. LighthouseProgramAddress = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" // MemoProgramAddress is the SPL Memo program address @@ -44,10 +41,21 @@ const ( // DefaultCommitment is the default commitment level for transactions DefaultCommitment = rpc.CommitmentConfirmed + // ConfirmInitialRetryDelay is the delay for the first ConfirmInitialAttempts + // confirmation polls. Solana slots are ~400ms; a 250ms poll catches a + // confirmation in the same slot window instead of waiting a full second. + ConfirmInitialRetryDelay = 250 * time.Millisecond + + // ConfirmInitialAttempts is how many confirmation polls use ConfirmInitialRetryDelay + // before falling back to ConfirmRetryDelay. 8×250ms + 28×1s preserves the + // ~30s budget of the previous 30×1s loop. + ConfirmInitialAttempts = 8 + // MaxConfirmAttempts is the maximum number of confirmation attempts - MaxConfirmAttempts = 30 + MaxConfirmAttempts = 36 - // ConfirmRetryDelay is the base delay between confirmation attempts + // ConfirmRetryDelay is the delay between confirmation attempts after the + // initial fast-poll window. ConfirmRetryDelay = 1 * time.Second // SettlementTTL is how long a transaction is held in the duplicate settlement cache. diff --git a/go/mechanisms/svm/exact/facilitator/duplicate_tx_test.go b/go/mechanisms/svm/exact/facilitator/duplicate_tx_test.go index 6f66474415..fe5fa62de5 100644 --- a/go/mechanisms/svm/exact/facilitator/duplicate_tx_test.go +++ b/go/mechanisms/svm/exact/facilitator/duplicate_tx_test.go @@ -12,12 +12,12 @@ import ( ) func TestFacilitatorInstructionConstraints(t *testing.T) { - t.Run("allows 3-6 instructions", func(t *testing.T) { + t.Run("allows 3-7 instructions", func(t *testing.T) { minInstructions := 3 - maxInstructions := 6 + maxInstructions := 7 assert.Equal(t, 3, minInstructions) - assert.Equal(t, 6, maxInstructions) + assert.Equal(t, 7, maxInstructions) }) t.Run("optional instructions may be Lighthouse or Memo", func(t *testing.T) { @@ -33,7 +33,7 @@ func TestFacilitatorInstructionConstraints(t *testing.T) { func TestErrorCodesForMitigationPlanning(t *testing.T) { t.Run("instruction count error", func(t *testing.T) { err := ErrTransactionInstructionsLength - assert.Equal(t, "invalid_exact_solana_payload_transaction_instructions_length", err) + assert.Equal(t, ErrTransactionInstructionsLength, err) }) } @@ -117,7 +117,7 @@ func TestDuplicateSettlementCache(t *testing.T) { t.Run("constructor wires the shared cache into the scheme", func(t *testing.T) { cache := svm.NewSettlementCache() - scheme := NewExactSvmScheme(nil, cache) + scheme := NewExactSvmScheme(nil, &Config{SettlementCache: cache}) assert.Same(t, cache, scheme.settlementCache, "scheme should hold the exact cache instance that was injected") }) diff --git a/go/mechanisms/svm/exact/facilitator/errors.go b/go/mechanisms/svm/exact/facilitator/errors.go index 7f8087daf9..a5cc6817c3 100644 --- a/go/mechanisms/svm/exact/facilitator/errors.go +++ b/go/mechanisms/svm/exact/facilitator/errors.go @@ -1,6 +1,8 @@ package facilitator -// Facilitator error constants for the exact SVM (Solana) scheme (V2) +import x402 "github.com/x402-foundation/x402/go/v2" + +// Facilitator error constants for the exact SVM scheme (V2) const ( // Verify errors ErrUnsupportedScheme = "invalid_exact_solana_unsupported_scheme" @@ -9,10 +11,15 @@ const ( ErrFeePayerNotManaged = "invalid_exact_solana_fee_payer_not_managed_by_facilitator" ErrInvalidPayloadTransaction = "invalid_exact_solana_payload_transaction" ErrTransactionCouldNotBeDecoded = "invalid_exact_solana_payload_transaction_could_not_be_decoded" + ErrSignatureInvalid = "invalid_exact_solana_payload_signature_invalid" + ErrExcessiveSigners = "invalid_exact_solana_payload_excessive_signers" ErrTransactionInstructionsLength = "invalid_exact_solana_payload_transaction_instructions_length" + ErrComputeLimitInstructionTooHigh = "invalid_exact_solana_payload_transaction_instructions_compute_limit_instruction_too_high" ErrUnknownFourthInstruction = "invalid_exact_solana_payload_unknown_fourth_instruction" ErrUnknownFifthInstruction = "invalid_exact_solana_payload_unknown_fifth_instruction" ErrUnknownSixthInstruction = "invalid_exact_solana_payload_unknown_sixth_instruction" + ErrUnknownSeventhInstruction = "invalid_exact_solana_payload_unknown_seventh_instruction" + ErrUnknownOptionalInstruction = "invalid_exact_solana_payload_unknown_optional_instruction" ErrComputeLimitInstruction = "invalid_exact_solana_payload_transaction_instructions_compute_limit_instruction" ErrComputePriceInstruction = "invalid_exact_solana_payload_transaction_instructions_compute_price_instruction" ErrComputePriceInstructionTooHigh = "invalid_exact_solana_payload_transaction_instructions_compute_price_instruction_too_high" @@ -20,7 +27,7 @@ const ( ErrFeePayerTransferringFunds = "invalid_exact_solana_payload_transaction_fee_payer_transferring_funds" ErrMintMismatch = "invalid_exact_solana_payload_mint_mismatch" ErrRecipientMismatch = "invalid_exact_solana_payload_recipient_mismatch" - ErrAmountInsufficient = "invalid_exact_solana_payload_amount_insufficient" + ErrAmountMismatch = "invalid_exact_solana_payload_amount_mismatch" ErrInvalidFeePayer = "invalid_exact_solana_invalid_fee_payer" ErrTransactionSigningFailed = "invalid_exact_solana_transaction_signing_failed" ErrTransactionSimulationFailed = "invalid_exact_solana_transaction_simulation_failed" @@ -30,9 +37,38 @@ const ( ErrMemoCount = "invalid_exact_solana_payload_memo_count" // Settle errors - ErrVerificationFailed = "invalid_exact_solana_verification_failed" - ErrFeePayerMismatch = "invalid_exact_solana_fee_payer_mismatch" - ErrTransactionFailed = "invalid_exact_solana_transaction_failed" - ErrTransactionConfirmationFailed = "invalid_exact_solana_transaction_confirmation_failed" - ErrDuplicateSettlement = "duplicate_settlement" + ErrVerificationFailed = "invalid_exact_solana_verification_failed" + ErrFeePayerMismatch = "invalid_exact_solana_fee_payer_mismatch" + ErrTransactionFailed = "invalid_exact_solana_transaction_failed" + ErrDuplicateSettlement = "duplicate_settlement" + ErrPostSettlementTransferNotConfirmed = "post_settlement_transfer_not_confirmed" + + ErrSmartWalletFeePayerNotIsolated = "invalid_exact_solana_smart_wallet_fee_payer_not_isolated" + ErrSmartWalletMalformedComputeBudget = "invalid_exact_solana_smart_wallet_malformed_compute_budget" + ErrSmartWalletMalformedComputeLimit = "invalid_exact_solana_smart_wallet_malformed_compute_limit" + ErrSmartWalletMalformedComputePrice = "invalid_exact_solana_smart_wallet_malformed_compute_price" + ErrSmartWalletComputeUnitsTooHigh = "invalid_exact_solana_smart_wallet_compute_units_too_high" + ErrSmartWalletPriorityFeeTooHigh = "invalid_exact_solana_smart_wallet_priority_fee_too_high" + ErrSmartWalletUnsupportedComputeBudget = "invalid_exact_solana_smart_wallet_unsupported_compute_budget_instruction" + ErrSmartWalletAltResolutionUnavailable = "invalid_exact_solana_smart_wallet_alt_resolution_not_available" + ErrSmartWalletAltResolutionFailed = "invalid_exact_solana_smart_wallet_alt_resolution_failed" + ErrSmartWalletVerificationUnavailable = "invalid_exact_solana_smart_wallet_verification_not_available" + ErrSmartWalletComputeBudgetViolation = "invalid_exact_solana_smart_wallet_compute_budget_violation" + ErrSmartWalletSimulationFailed = "invalid_exact_solana_smart_wallet_simulation_failed" + ErrSmartWalletCannotDeriveATA = "invalid_exact_solana_smart_wallet_cannot_derive_destination_ata" + ErrSmartWalletNoTransferInSimulation = "invalid_exact_solana_smart_wallet_no_transfer_in_simulation" + ErrSmartWalletTransferMismatch = "invalid_exact_solana_smart_wallet_transfer_mismatch" + ErrSmartWalletMultipleMatchingTransfers = "invalid_exact_solana_smart_wallet_multiple_matching_transfers" + ErrSmartWalletProgramNotAllowed = "invalid_exact_solana_smart_wallet_program_not_allowed" ) + +// ErrSettlementPending is the non-terminal settle error reason used when a +// transaction was broadcast but ConfirmTransaction couldn't observe its +// confirmation in time. It always carries the broadcast signature (as +// SettleError.Transaction) so a caller can reconcile onchain, and mirrors +// x402.ErrSettlementPending / evm.ErrSettlementPending so +// x402ResourceServer's generic single-retry-on-settlement_pending logic +// (see settleWithPendingRetry in server.go) recognizes it uniformly across +// schemes/networks. Replaces the former ErrTransactionConfirmationFailed, +// which was terminal and gave callers no reconciliation path. +const ErrSettlementPending = x402.ErrSettlementPending diff --git a/go/mechanisms/svm/exact/facilitator/pending_settlement_test.go b/go/mechanisms/svm/exact/facilitator/pending_settlement_test.go new file mode 100644 index 0000000000..8363f87a5c --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/pending_settlement_test.go @@ -0,0 +1,369 @@ +package facilitator + +import ( + "context" + "errors" + "testing" + + solana "github.com/gagliardetto/solana-go" + computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/programs/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + "github.com/x402-foundation/x402/go/v2/types" +) + +// mockExactSvmSigner is a fully-mocked FacilitatorSvmSigner: SignTransaction +// and SimulateTransaction are no-ops (Verify's structural checks run purely +// against the decoded transaction, no real signature/on-chain-state +// validation is needed), while SendTransaction/ConfirmTransaction are +// configurable so tests can force the settlement_pending / reconciliation +// paths deterministically. +type mockExactSvmSigner struct { + addresses []solana.PublicKey + sendSignature solana.Signature + sendErr error + confirmErr error + sendCalls int + signCalls int + simulateCalls int + confirmCalls int + confirmedSigs []solana.Signature +} + +func (m *mockExactSvmSigner) GetAddresses(_ context.Context, _ string) []solana.PublicKey { + return m.addresses +} +func (m *mockExactSvmSigner) SignTransaction(_ context.Context, _ *solana.Transaction, _ solana.PublicKey, _ string) error { + m.signCalls++ + return nil +} +func (m *mockExactSvmSigner) SimulateTransaction(_ context.Context, _ *solana.Transaction, _ string) error { + m.simulateCalls++ + return nil +} +func (m *mockExactSvmSigner) SendTransaction(_ context.Context, _ *solana.Transaction, _ string) (solana.Signature, error) { + m.sendCalls++ + if m.sendErr != nil { + return solana.Signature{}, m.sendErr + } + return m.sendSignature, nil +} +func (m *mockExactSvmSigner) ConfirmTransaction(_ context.Context, sig solana.Signature, _ string) error { + m.confirmCalls++ + m.confirmedSigs = append(m.confirmedSigs, sig) + return m.confirmErr +} + +// buildValidExactSvmFixture constructs a syntactically valid exact-SVM +// payment payload/requirements pair: ComputeLimit + ComputePrice + +// TransferChecked. The token owner signs; the fee-payer slot is left unsigned +// for the facilitator to fill at settle. +func buildValidExactSvmFixture(t *testing.T) (types.PaymentPayload, types.PaymentRequirements, solana.PublicKey) { + t.Helper() + f := buildExactFixture(t) + return f.payload, f.requirements, f.facilitatorAddr +} + +type exactFixture struct { + payload types.PaymentPayload + requirements types.PaymentRequirements + facilitatorAddr solana.PublicKey + ownerKey solana.PrivateKey + tx *solana.Transaction + mint solana.PublicKey + payTo solana.PublicKey + destATA solana.PublicKey +} + +func signTransaction(t *testing.T, tx *solana.Transaction, key solana.PrivateKey) { + t.Helper() + messageBytes, err := tx.Message.MarshalBinary() + require.NoError(t, err) + signature, err := key.Sign(messageBytes) + require.NoError(t, err) + index, err := tx.GetAccountIndex(key.PublicKey()) + require.NoError(t, err) + n := int(tx.Message.Header.NumRequiredSignatures) + if len(tx.Signatures) < n { + sigs := make([]solana.Signature, n) + copy(sigs, tx.Signatures) + tx.Signatures = sigs + } + tx.Signatures[index] = signature +} + +func lighthouseInstruction() solana.Instruction { + return solana.NewInstruction( + solana.MustPublicKeyFromBase58(svm.LighthouseProgramAddress), + solana.AccountMetaSlice{}, + []byte{0x01}, + ) +} + +func buildExactFixture(t *testing.T) exactFixture { + return buildExactFixtureWithOptional(t) +} + +func buildExactFixtureWithOptional(t *testing.T, extra ...solana.Instruction) exactFixture { + t.Helper() + + facilitatorAddr := solana.NewWallet().PrivateKey.PublicKey() + ownerWallet := solana.NewWallet() + owner := ownerWallet.PublicKey() + mint := solana.NewWallet().PrivateKey.PublicKey() + payTo := solana.NewWallet().PrivateKey.PublicKey() + + sourceATA, _, err := solana.FindAssociatedTokenAddress(owner, mint) + require.NoError(t, err) + destATA, _, err := solana.FindAssociatedTokenAddress(payTo, mint) + require.NoError(t, err) + + cuLimit, err := computebudget.NewSetComputeUnitLimitInstructionBuilder(). + SetUnits(200000). + ValidateAndBuild() + require.NoError(t, err) + + cuPrice, err := computebudget.NewSetComputeUnitPriceInstructionBuilder(). + SetMicroLamports(1000). + ValidateAndBuild() + require.NoError(t, err) + + transferIx, err := token.NewTransferCheckedInstructionBuilder(). + SetAmount(1000). + SetDecimals(6). + SetSourceAccount(sourceATA). + SetMintAccount(mint). + SetDestinationAccount(destATA). + SetOwnerAccount(owner). + ValidateAndBuild() + require.NoError(t, err) + + blockhash, err := solana.HashFromBase58("5Tx8F3jgSHx21CbtjwmdaKPLM5tWmreWAnPrbqHomSJF") + require.NoError(t, err) + + builder := solana.NewTransactionBuilder(). + AddInstruction(cuLimit). + AddInstruction(cuPrice). + AddInstruction(transferIx) + for _, ix := range extra { + builder = builder.AddInstruction(ix) + } + tx, err := builder. + SetRecentBlockHash(blockhash). + SetFeePayer(facilitatorAddr). + Build() + require.NoError(t, err) + + signTransaction(t, tx, ownerWallet.PrivateKey) + + base64Tx, err := svm.EncodeTransaction(tx) + require.NoError(t, err) + + svmPayload := &svm.ExactSvmPayload{Transaction: base64Tx} + requirements := types.PaymentRequirements{ + Scheme: svm.SchemeExact, + Network: svm.SolanaDevnetCAIP2, + Asset: mint.String(), + Amount: "1000", + PayTo: payTo.String(), + Extra: map[string]interface{}{ + "feePayer": facilitatorAddr.String(), + }, + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: svmPayload.ToMap(), + Accepted: requirements, + } + return exactFixture{ + payload: payload, + requirements: requirements, + facilitatorAddr: facilitatorAddr, + ownerKey: ownerWallet.PrivateKey, + tx: tx, + mint: mint, + payTo: payTo, + destATA: destATA, + } +} + +// Exercises the PendingSettlementStore fast path wired into ExactSvmScheme.Settle +// (see scheme.go): a settle attempt whose ConfirmTransaction wait fails must +// populate the store keyed by the transaction's message hash; a subsequent +// settle for the identical payload must hit that entry, skip +// verify/sign/send entirely, and reconcile against the already-broadcast +// signature via reconcilePendingSettlement. Mirrors the TS/Python SVM exact +// pending-settlement test suites. + +func TestExactSvmScheme_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + signer := &mockExactSvmSigner{ + addresses: []solana.PublicKey{facilitatorAddr}, + sendSignature: solana.SignatureFromBytes(append([]byte{1}, make([]byte, 63)...)), + } + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + + _, ok, _ := scheme.pendingStore.Get(context.Background(), txKey) + assert.False(t, ok, "successful settlement must not leave a pending entry") +} + +func TestExactSvmScheme_PendingSettlementStore_CacheMissConfirmFailurePopulatesStore(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + wantSig := solana.SignatureFromBytes(append([]byte{2}, make([]byte, 63)...)) + signer := &mockExactSvmSigner{ + addresses: []solana.PublicKey{facilitatorAddr}, + sendSignature: wantSig, + confirmErr: errors.New("rpc: confirmation timeout"), + } + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + assert.Equal(t, wantSig.String(), se.Transaction) + + stored, ok, _ := scheme.pendingStore.Get(context.Background(), txKey) + require.True(t, ok, "confirm-wait failure must populate the pending-settlement store") + assert.Equal(t, wantSig.String(), stored) + + // The dedup lock must survive a confirm-timeout: the transaction really + // was broadcast, so a fresh Settle call for the same payload (landing + // without a PendingSettlementStore, e.g. a different unaware caller, or + // after the entry's TTL expires) must not be allowed to re-verify and + // re-send. Mirrors TS/Go-upto, which both leave the lock in place here. + _, held := scheme.settlementCache.Entries()[txKey] + assert.True(t, held, "dedup lock must remain held after a confirm-timeout so a fresh settle can't re-send") +} + +func TestExactSvmScheme_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + priorSig := solana.SignatureFromBytes(append([]byte{3}, make([]byte, 63)...)) + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + require.NoError(t, scheme.pendingStore.Set(context.Background(), txKey, priorSig.String())) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, priorSig.String(), resp.Transaction) + assert.Equal(t, 0, signer.sendCalls, "reconciliation fast path must never re-broadcast") + assert.Equal(t, 1, signer.confirmCalls) + + _, ok, _ := scheme.pendingStore.Get(context.Background(), txKey) + assert.False(t, ok, "successful reconciliation must clear the pending entry") +} + +func TestExactSvmScheme_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + priorSig := solana.SignatureFromBytes(append([]byte{4}, make([]byte, 63)...)) + signer := &mockExactSvmSigner{ + addresses: []solana.PublicKey{facilitatorAddr}, + confirmErr: errors.New("still pending"), + } + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + require.NoError(t, scheme.pendingStore.Set(context.Background(), txKey, priorSig.String())) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + assert.Equal(t, priorSig.String(), se.Transaction) + assert.Equal(t, 0, signer.sendCalls, "reconciliation fast path must never re-broadcast") + + stored, ok, _ := scheme.pendingStore.Get(context.Background(), txKey) + require.True(t, ok) + assert.Equal(t, priorSig.String(), stored) +} + +func TestExactSvmScheme_PendingSettlementStore_TerminalVerifyFailureNeverTouchesStore(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + // Corrupt the requirements amount so Verify rejects with a terminal reason + // (amount mismatch) before any signing/broadcasting occurs. + requirements.Amount = "999999999" + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrAmountMismatch, se.ErrorReason) + assert.Equal(t, 0, signer.sendCalls) + + _, ok, _ := scheme.pendingStore.Get(context.Background(), txKey) + assert.False(t, ok) +} + +// TestExactSvmScheme_SettlementCache_ReleasedOnTerminalFeePayerMismatch guards +// against the dedup lock leaking on a terminal (never-broadcast) failure +// reached after IsDuplicate has already claimed it. If the lock isn't +// released here, a legitimate retry of the identical payload (e.g. after the +// caller fixes the feePayer mismatch — or simply resubmits unchanged and +// expects the same terminal error, not a bogus "duplicate") would be wrongly +// rejected as a duplicate for the rest of the SettlementTTL window. +func TestExactSvmScheme_SettlementCache_ReleasedOnTerminalFeePayerMismatch(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + // Point requirements at a *different* facilitator-managed address than the + // transaction's actual fee payer. Settle claims the dedup lock before + // verify; verify then rejects AccountKeys[0] != extra.feePayer. The lock + // must still be released so a retry is not a false duplicate. + otherManagedAddr := solana.NewWallet().PrivateKey.PublicKey() + requirements.Extra["feePayer"] = otherManagedAddr.String() + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr, otherManagedAddr}} + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrFeePayerMismatch, se.ErrorReason) + + _, held := scheme.settlementCache.Entries()[txKey] + assert.False(t, held, "dedup lock must be released on a terminal never-broadcast failure") + + // A retry of the identical payload must reach the same terminal error again, + // not be rejected as a duplicate. + _, err = scheme.Settle(context.Background(), payload, requirements, nil) + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrFeePayerMismatch, se.ErrorReason) +} + +func TestExactSvmScheme_PendingSettlementStore_DefaultsToFreshInMemoryStoreWhenNoneInjected(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + signer := &mockExactSvmSigner{ + addresses: []solana.PublicKey{facilitatorAddr}, + sendSignature: solana.SignatureFromBytes(append([]byte{5}, make([]byte, 63)...)), + } + scheme := NewExactSvmScheme(signer) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) +} + +// messageHashForPayload decodes the payload's transaction and derives the +// PendingSettlementStore key exactly as Settle does, so tests can assert on +// store state without duplicating scheme-internal decoding logic. +func messageHashForPayload(t *testing.T, payload types.PaymentPayload) string { + t.Helper() + solanaPayload, err := svm.PayloadFromMap(payload.Payload) + require.NoError(t, err) + tx, err := svm.DecodeTransaction(solanaPayload.Transaction) + require.NoError(t, err) + key, err := svm.MessageHash(tx) + require.NoError(t, err) + return key +} diff --git a/go/mechanisms/svm/exact/facilitator/scheme.go b/go/mechanisms/svm/exact/facilitator/scheme.go index f6f8697d43..9e6730304e 100644 --- a/go/mechanisms/svm/exact/facilitator/scheme.go +++ b/go/mechanisms/svm/exact/facilitator/scheme.go @@ -2,13 +2,14 @@ package facilitator import ( "context" + "encoding/binary" "errors" "fmt" "math/rand/v2" "strconv" + "sync" solana "github.com/gagliardetto/solana-go" - computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" "github.com/gagliardetto/solana-go/programs/token" x402 "github.com/x402-foundation/x402/go/v2" @@ -16,25 +17,85 @@ import ( "github.com/x402-foundation/x402/go/v2/types" ) +// Config is the optional configuration of the SVM exact facilitator. +type Config struct { + SettlementCache *svm.SettlementCache + EnableSmartWalletVerification bool + SmartWalletMaxComputeUnits *uint32 + SmartWalletMaxPriorityFeeMicroLamports *uint64 + SmartWalletAllowedPrograms []string + MaxPriorityFeeMicroLamports *uint64 + MaxComputeUnits *uint32 + MaxRequiredSignatures *uint8 +} + // ExactSvmScheme implements the SchemeNetworkFacilitator interface for SVM (Solana) exact payments (V2) type ExactSvmScheme struct { signer svm.FacilitatorSvmSigner settlementCache *svm.SettlementCache + pendingStore x402.PendingSettlementStore + config Config +} + +type verifyResult struct { + response *x402.VerifyResponse + tx *solana.Transaction + smartWallet bool + matchedTransfer *transferCheckedInfo } // NewExactSvmScheme creates a new ExactSvmScheme. -// An optional SettlementCache may be provided to share deduplication state -// across V1 and V2 instances; if nil a new cache is created. -func NewExactSvmScheme(signer svm.FacilitatorSvmSigner, cache ...*svm.SettlementCache) *ExactSvmScheme { - var c *svm.SettlementCache - if len(cache) > 0 && cache[0] != nil { - c = cache[0] - } else { - c = svm.NewSettlementCache() +// +// Panics on a misconfiguration the facilitator could otherwise only discover +// mid-payment. Operators learn about an unusable limit at startup, as they do in +// the TypeScript SDK. A zero compute-unit or signature ceiling means unset and +// takes the documented default. +func NewExactSvmScheme(signer svm.FacilitatorSvmSigner, config ...*Config) *ExactSvmScheme { + cfg := Config{} + if len(config) > 0 && config[0] != nil { + cfg = *config[0] + } + if cfg.MaxComputeUnits != nil { + assertPositive("maxComputeUnits", int64(*cfg.MaxComputeUnits)) + } + if cfg.MaxRequiredSignatures != nil { + assertPositive("maxRequiredSignatures", int64(*cfg.MaxRequiredSignatures)) + } + if cfg.SmartWalletMaxComputeUnits != nil { + assertPositive("smartWalletMaxComputeUnits", int64(*cfg.SmartWalletMaxComputeUnits)) + } + + cache := cfg.SettlementCache + if cache == nil { + cache = svm.NewSettlementCache() + } + + if cfg.EnableSmartWalletVerification { + if _, ok := signer.(svm.SmartWalletRPCCapabilities); !ok { + panic("exact svm facilitator: enableSmartWalletVerification requires SmartWalletRPCCapabilities on the signer") + } } + return &ExactSvmScheme{ signer: signer, - settlementCache: c, + settlementCache: cache, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + config: cfg, + } +} + +func assertPositive(name string, value int64) { + if value < 1 { + panic(fmt.Sprintf("exact svm facilitator: %s must be >= 1, received %d", name, value)) + } +} + +// SetPendingSettlementStore overrides the default in-memory PendingSettlementStore +// used to reconcile a transaction that broadcast successfully but whose +// confirmation wait timed out (settlement_pending). A nil store is a no-op. +func (f *ExactSvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store } } @@ -57,15 +118,20 @@ func (f *ExactSvmScheme) GetExtra(network x402.Network) map[string]interface{} { // Randomly select from available addresses to distribute load randomIndex := rand.IntN(len(addresses)) - return map[string]interface{}{ + extra := map[string]interface{}{ "feePayer": addresses[randomIndex].String(), } + if f.config.EnableSmartWalletVerification { + extra["features"] = map[string]interface{}{ + "smartWalletSupported": true, + } + } + return extra } // GetSigners returns signer addresses used by this facilitator. // For SVM, returns all available fee payer addresses for the given network. -func (f *ExactSvmScheme) GetSigners(network x402.Network) []string { - addresses := f.signer.GetAddresses(context.Background(), string(network)) +func publicKeysToStrings(addresses []solana.PublicKey) []string { result := make([]string, len(addresses)) for i, addr := range addresses { result[i] = addr.String() @@ -73,6 +139,12 @@ func (f *ExactSvmScheme) GetSigners(network x402.Network) []string { return result } +// GetSigners returns signer addresses used by this facilitator. +// For SVM, returns all available fee payer addresses for the given network. +func (f *ExactSvmScheme) GetSigners(network x402.Network) []string { + return publicKeysToStrings(f.signer.GetAddresses(context.Background(), string(network))) +} + // Verify verifies a V2 payment payload against requirements func (f *ExactSvmScheme) Verify( ctx context.Context, @@ -80,6 +152,18 @@ func (f *ExactSvmScheme) Verify( requirements types.PaymentRequirements, _ *x402.FacilitatorContext, ) (*x402.VerifyResponse, error) { + result, err := f.verify(ctx, payload, requirements) + if err != nil { + return nil, err + } + return result.response, nil +} + +func (f *ExactSvmScheme) verify( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, +) (*verifyResult, error) { network := x402.Network(requirements.Network) // Step 1: Validate Payment Requirements @@ -131,30 +215,167 @@ func (f *ExactSvmScheme) Verify( return nil, x402.NewVerifyError(ErrTransactionCouldNotBeDecoded, "", err.Error()) } - // Allow 3-6 instructions: + if f.config.MaxRequiredSignatures != nil && tx.Message.Header.NumRequiredSignatures > *f.config.MaxRequiredSignatures { + return nil, x402.NewVerifyError(ErrExcessiveSigners, "", ErrExcessiveSigners) + } + + if err := VerifyRequiredSignatures(tx, feePayerStr); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + if err := f.resolveLookups(ctx, tx, string(requirements.Network)); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + staticErr := f.verifyStaticPath(ctx, tx, requirements, signerAddressStrs) + if staticErr == nil { + payer, _ := svm.GetTokenPayerFromTransaction(tx) + return &verifyResult{ + response: &x402.VerifyResponse{IsValid: true, Payer: payer}, + tx: tx, + }, nil + } + + if f.config.EnableSmartWalletVerification && isLayoutRecoverable(staticErr) { + if err := f.assertSmartWalletAllowlist(tx); err != nil { + return nil, err + } + feePayer, err := solana.PublicKeyFromBase58(feePayerStr) + if err != nil { + return nil, x402.NewVerifyError(ErrInvalidFeePayer, "", err.Error()) + } + caps := f.signer.(svm.SmartWalletRPCCapabilities) + maxCU := defaultSmartWalletMaxComputeUnits + if f.config.SmartWalletMaxComputeUnits != nil { + maxCU = *f.config.SmartWalletMaxComputeUnits + } + maxPriorityFee := defaultSmartWalletMaxPriorityFeeMicroLamports + if f.config.SmartWalletMaxPriorityFeeMicroLamports != nil { + maxPriorityFee = *f.config.SmartWalletMaxPriorityFeeMicroLamports + } + matched, err := verifySmartWalletTransaction( + ctx, tx, requirements, caps, feePayer, signerAddressStrs, maxCU, maxPriorityFee, + ) + if err != nil { + return nil, err + } + return &verifyResult{ + response: &x402.VerifyResponse{IsValid: true, Payer: matched.authority.String()}, + tx: tx, + smartWallet: true, + matchedTransfer: matched, + }, nil + } + + return nil, staticErr +} + +func isLayoutRecoverable(err error) bool { + var ve *x402.VerifyError + reason := err.Error() + if errors.As(err, &ve) { + reason = ve.InvalidReason + } + switch reason { + case ErrTransactionInstructionsLength, + ErrNoTransferInstruction, + ErrUnknownFourthInstruction, + ErrUnknownFifthInstruction, + ErrUnknownSixthInstruction, + ErrUnknownOptionalInstruction, + ErrComputeLimitInstruction, + ErrComputePriceInstruction: + return true + default: + return false + } +} + +func (f *ExactSvmScheme) assertSmartWalletAllowlist(tx *solana.Transaction) error { + allowed := defaultSmartWalletAllowedPrograms + if len(f.config.SmartWalletAllowedPrograms) > 0 { + allowed = f.config.SmartWalletAllowedPrograms + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, program := range allowed { + allowedSet[program] = struct{}{} + } + memoPubkey := solana.MustPublicKeyFromBase58(svm.MemoProgramAddress) + for _, ix := range tx.Message.Instructions { + programID, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + return x402.NewVerifyError(ErrSmartWalletProgramNotAllowed, "", err.Error()) + } + if programID.Equals(solana.ComputeBudget) || programID.Equals(memoPubkey) { + continue + } + if _, ok := allowedSet[programID.String()]; !ok { + return x402.NewVerifyError( + fmt.Sprintf("%s: %s", ErrSmartWalletProgramNotAllowed, programID), + "", + programID.String(), + ) + } + } + return nil +} + +func (f *ExactSvmScheme) resolveLookups(ctx context.Context, tx *solana.Transaction, network string) error { + lookups := tx.Message.GetAddressTableLookups() + if len(lookups) == 0 { + return nil + } + caps, ok := f.signer.(svm.SmartWalletRPCCapabilities) + if !ok { + return fmt.Errorf("%s: transaction uses Address Lookup Tables but signer does not implement SmartWalletRPCCapabilities", + ErrSmartWalletAltResolutionUnavailable) + } + tables := make([]solana.PublicKey, len(lookups)) + for i, lookup := range lookups { + tables[i] = lookup.AccountKey + } + resolved, err := caps.FetchAddressLookupTables(ctx, tables, network) + if err != nil { + return err + } + if err := tx.Message.SetAddressTables(resolved); err != nil { + return err + } + return tx.Message.ResolveLookups() +} + +func (f *ExactSvmScheme) verifyStaticPath( + ctx context.Context, + tx *solana.Transaction, + requirements types.PaymentRequirements, + signerAddressStrs []string, +) error { + // Allow 3-7 instructions: // - 3 instructions: ComputeLimit + ComputePrice + TransferChecked // - 4 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse or Memo // - 5 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse + Lighthouse or Memo // - 6 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse + Lighthouse + Memo + // - 7 instructions: + a third wallet-injected Lighthouse (Phantom, see #2097) // See: https://github.com/x402-foundation/x402/issues/828 + // and: https://github.com/x402-foundation/x402/issues/2097 numInstructions := len(tx.Message.Instructions) - if numInstructions < 3 || numInstructions > 6 { - return nil, x402.NewVerifyError(ErrTransactionInstructionsLength, "", fmt.Sprintf("transaction instructions length mismatch: %d < 3 or %d > 6", numInstructions, numInstructions)) + if numInstructions < 3 || numInstructions > 7 { + return x402.NewVerifyError(ErrTransactionInstructionsLength, "", fmt.Sprintf("transaction instructions length mismatch: %d < 3 or %d > 7", numInstructions, numInstructions)) } // Step 3: Verify Compute Budget Instructions if err := f.verifyComputeLimitInstruction(tx, tx.Message.Instructions[0]); err != nil { - return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + return x402.NewVerifyError(err.Error(), "", err.Error()) } if err := f.verifyComputePriceInstruction(tx, tx.Message.Instructions[1]); err != nil { - return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + return x402.NewVerifyError(err.Error(), "", err.Error()) } // Extract payer from transaction payer, err := svm.GetTokenPayerFromTransaction(tx) if err != nil { - return nil, x402.NewVerifyError(ErrNoTransferInstruction, payer, err.Error()) + return x402.NewVerifyError(ErrNoTransferInstruction, payer, err.Error()) } // V2: payload.Accepted.Network is already validated by scheme lookup @@ -172,7 +393,7 @@ func (f *ExactSvmScheme) Verify( // Step 4: Verify Transfer Instruction if err := f.verifyTransferInstruction(tx, tx.Message.Instructions[2], reqStruct, signerAddressStrs); err != nil { - return nil, x402.NewVerifyError(err.Error(), payer, err.Error()) + return x402.NewVerifyError(err.Error(), payer, err.Error()) } // Step 5: Verify optional instructions (if present) @@ -185,20 +406,28 @@ func (f *ExactSvmScheme) Verify( ErrUnknownFourthInstruction, ErrUnknownFifthInstruction, ErrUnknownSixthInstruction, + ErrUnknownSeventhInstruction, } for i, instruction := range optionalInstructions { - progID := tx.Message.AccountKeys[instruction.ProgramIDIndex] + progID, progErr := tx.Message.Program(instruction.ProgramIDIndex) + if progErr != nil { + reason := ErrUnknownOptionalInstruction + if i < len(invalidReasons) { + reason = invalidReasons[i] + } + return x402.NewVerifyError(reason, payer, progErr.Error()) + } if progID.Equals(lighthousePubkey) || progID.Equals(memoPubkey) { continue } - reason := ErrUnknownSixthInstruction + reason := ErrUnknownOptionalInstruction if i < len(invalidReasons) { reason = invalidReasons[i] } - return nil, x402.NewVerifyError(reason, payer, fmt.Sprintf("unknown optional instruction: %s", progID.String())) + return x402.NewVerifyError(reason, payer, fmt.Sprintf("unknown optional instruction: %s", progID.String())) } // Step 5b: Verify memo content matches extra.memo when present @@ -206,44 +435,32 @@ func (f *ExactSvmScheme) Verify( var memoCount int var actualMemoData []byte for _, instruction := range optionalInstructions { - progID := tx.Message.AccountKeys[instruction.ProgramIDIndex] + progID, progErr := tx.Message.Program(instruction.ProgramIDIndex) + if progErr != nil { + continue + } if progID.Equals(memoPubkey) { memoCount++ actualMemoData = instruction.Data } } if memoCount != 1 { - return nil, x402.NewVerifyError(ErrMemoCount, payer, "expected exactly one memo instruction when extra.memo is present") + return x402.NewVerifyError(ErrMemoCount, payer, "expected exactly one memo instruction when extra.memo is present") } if string(actualMemoData) != expectedMemo { - return nil, x402.NewVerifyError(ErrMemoMismatch, payer, "memo data does not match extra.memo") + return x402.NewVerifyError(ErrMemoMismatch, payer, "memo data does not match extra.memo") } } } - // Step 6: Sign and Simulate Transaction + // Step 6: Simulate Transaction // CRITICAL: Simulation proves transaction will succeed (catches insufficient balance, invalid accounts, etc) - - // feePayer already validated in Step 1 - feePayer, err := solana.PublicKeyFromBase58(feePayerStr) - if err != nil { - return nil, x402.NewVerifyError(ErrInvalidFeePayer, payer, err.Error()) - } - - // Sign transaction with the feePayer's signer - if err := f.signer.SignTransaction(ctx, tx, feePayer, string(requirements.Network)); err != nil { - return nil, x402.NewVerifyError(ErrTransactionSigningFailed, payer, err.Error()) - } - - // Simulate transaction to verify it would succeed + // Signatures are verified locally; the fee-payer slot is unsigned until settle. if err := f.signer.SimulateTransaction(ctx, tx, string(requirements.Network)); err != nil { - return nil, x402.NewVerifyError(ErrTransactionSimulationFailed, payer, err.Error()) + return x402.NewVerifyError(ErrTransactionSimulationFailed, payer, err.Error()) } - return &x402.VerifyResponse{ - IsValid: true, - Payer: payer, - }, nil + return nil } // Settle settles a payment by submitting the transaction (V2) @@ -256,141 +473,276 @@ func (f *ExactSvmScheme) Settle( ) (*x402.SettleResponse, error) { network := x402.Network(requirements.Network) - // First verify the payment - verifyResp, err := f.Verify(ctx, payload, requirements, fctx) - if err != nil { - // Convert VerifyError to SettleError - ve := &x402.VerifyError{} - if errors.As(err, &ve) { - return nil, x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) - } - return nil, x402.NewSettleError(ErrVerificationFailed, "", network, "", err.Error()) - } - - // Parse payload + // Parse and decode the transaction up front (no RPC calls) so we can key + // the PendingSettlementStore on the message hash before doing any + // verify/sign/send work. solanaPayload, err := svm.PayloadFromMap(payload.Payload) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) } - - // Decode transaction before the cache check so we can key on the message hash. tx, err := svm.DecodeTransaction(solanaPayload.Transaction) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) } - - // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). + // Keyed on message hash (immune to mutable fee-payer sig at slot 0); shared + // by the duplicate-settlement check and the PendingSettlementStore below. txKey, err := svm.MessageHash(tx) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) + } + + // Pending-settlement fast path: a prior settle for this exact transaction + // broadcast successfully but its ConfirmTransaction wait failed. Reconcile + // against the already-broadcast signature instead of re-verifying and + // re-sending: Solana transactions embed a recent blockhash that expires + // (so a resend can fail even when the original is still perfectly valid), + // and if the original actually did land, a second verify's balance-based + // simulation could now spuriously fail (funds already moved). + if f.pendingStore != nil { + if sigStr, hit, _ := f.pendingStore.Get(ctx, txKey); hit { + // Remove before reconciling (rather than after) so a concurrent + // retry of the same payload misses here instead of also + // reconciling: it falls through to the settlementCache dedup check + // below, which independently rejects it as a duplicate. + _ = f.pendingStore.Delete(ctx, txKey) + // Best-effort payer for the response; a decode failure here doesn't + // block reconciliation (the payload already broadcast successfully). + payer, _ := svm.GetTokenPayerFromTransaction(tx) + isSmartWallet := f.config.EnableSmartWalletVerification && !hasStaticTransferLayout(tx) + return f.reconcilePendingSettlement(ctx, txKey, sigStr, payer, network, string(requirements.Network), isSmartWallet, requirements) + } } + + // Duplicate settlement check keyed on message hash. if f.settlementCache.IsDuplicate(txKey) { - return nil, x402.NewSettleError(ErrDuplicateSettlement, verifyResp.Payer, network, "", "duplicate transaction") + payer, _ := svm.GetTokenPayerFromTransaction(tx) + return nil, x402.NewSettleError(ErrDuplicateSettlement, payer, network, "", "duplicate transaction") + } + + // Everything below until a successful broadcast is a terminal, never-broadcast + // failure and must release the dedup lock just claimed above — otherwise a + // legitimate retry of the same payload is wrongly rejected as a duplicate for + // the rest of the SettlementTTL window. releaseLock is turned off once the + // transaction is actually broadcast, since a confirmation timeout after that + // point must keep the lock held (see ConfirmTransaction below). + releaseLock := true + defer func() { + if releaseLock { + f.settlementCache.Delete(txKey) + } + }() + + // First verify the payment + result, err := f.verify(ctx, payload, requirements) + if err != nil { + // Convert VerifyError to SettleError + ve := &x402.VerifyError{} + if errors.As(err, &ve) { + return nil, x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) + } + return nil, x402.NewSettleError(ErrVerificationFailed, "", network, "", err.Error()) } - // Extract and validate feePayer from requirements matches transaction feePayerStr, ok := requirements.Extra["feePayer"].(string) if !ok { - return nil, x402.NewSettleError(ErrMissingFeePayer, verifyResp.Payer, network, "", "") + return nil, x402.NewSettleError(ErrMissingFeePayer, result.response.Payer, network, "", "") } expectedFeePayer, err := solana.PublicKeyFromBase58(feePayerStr) if err != nil { - return nil, x402.NewSettleError(ErrInvalidFeePayer, verifyResp.Payer, network, "", err.Error()) - } - - // Verify transaction feePayer matches requirements - actualFeePayer := tx.Message.AccountKeys[0] // First account is fee payer - if actualFeePayer != expectedFeePayer { - return nil, x402.NewSettleError(ErrFeePayerMismatch, verifyResp.Payer, network, "", - fmt.Sprintf("expected %s, got %s", expectedFeePayer, actualFeePayer)) + return nil, x402.NewSettleError(ErrInvalidFeePayer, result.response.Payer, network, "", err.Error()) + } + + var ( + signErr error + preBalance uint64 + hasPreBalance bool + ) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + // Sign with the feePayer's signer + signErr = f.signer.SignTransaction(ctx, tx, expectedFeePayer, string(requirements.Network)) + }() + if result.smartWallet && result.matchedTransfer != nil { + if caps, ok := f.signer.(svm.SmartWalletRPCCapabilities); ok { + wg.Add(1) + dest := result.matchedTransfer.destination + go func() { + defer wg.Done() + b, exists, balErr := caps.GetTokenAccountBalance(ctx, dest, string(requirements.Network)) + if balErr == nil && exists { + preBalance = b + hasPreBalance = true + } + }() + } } - - // Sign with the feePayer's signer - if err := f.signer.SignTransaction(ctx, tx, expectedFeePayer, string(requirements.Network)); err != nil { - return nil, x402.NewSettleError(ErrTransactionFailed, verifyResp.Payer, network, "", err.Error()) + wg.Wait() + if signErr != nil { + return nil, x402.NewSettleError(ErrTransactionFailed, result.response.Payer, network, "", signErr.Error()) } // Send transaction to network signature, err := f.signer.SendTransaction(ctx, tx, string(requirements.Network)) if err != nil { - f.settlementCache.Delete(txKey) - return nil, x402.NewSettleError(ErrTransactionFailed, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrTransactionFailed, result.response.Payer, network, "", err.Error()) } + releaseLock = false // Wait for confirmation if err := f.signer.ConfirmTransaction(ctx, signature, string(requirements.Network)); err != nil { - f.settlementCache.Delete(txKey) - return nil, x402.NewSettleError(ErrTransactionConfirmationFailed, verifyResp.Payer, network, signature.String(), err.Error()) + // Broadcast succeeded but confirmation couldn't be observed in time. + // Non-terminal: leave the dedup lock in place (a fresh broadcast would + // risk double-sending) and record the signature so a retry reconciles + // via the pending-settlement fast path above instead of re-verifying + // and re-sending. + return nil, svm.RecordPendingOrTerminal(ctx, f.pendingStore, txKey, signature.String(), result.response.Payer, network, ErrTransactionFailed, err) + } + if f.pendingStore != nil { + _ = f.pendingStore.Delete(ctx, txKey) + } + + if result.smartWallet { + caps, ok := f.signer.(svm.SmartWalletRPCCapabilities) + if !ok || !f.postSettlementVerified(ctx, caps, signature, string(requirements.Network), requirements, result, preBalance, hasPreBalance) { + return nil, x402.NewSettleError(ErrPostSettlementTransferNotConfirmed, result.response.Payer, network, signature.String(), ErrPostSettlementTransferNotConfirmed) + } } return &x402.SettleResponse{ Success: true, Transaction: signature.String(), Network: network, - Payer: verifyResp.Payer, + Payer: result.response.Payer, }, nil } -// verifyComputeLimitInstruction verifies the compute unit limit instruction -func (f *ExactSvmScheme) verifyComputeLimitInstruction(tx *solana.Transaction, inst solana.CompiledInstruction) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] +func (f *ExactSvmScheme) postSettlementVerified( + ctx context.Context, + caps svm.SmartWalletRPCCapabilities, + signature solana.Signature, + network string, + requirements types.PaymentRequirements, + result *verifyResult, + preBalance uint64, + hasPreBalance bool, +) bool { + var balanceBefore *uint64 + if hasPreBalance { + balanceBefore = &preBalance + } + var knownATA *solana.PublicKey + if result.matchedTransfer != nil { + dest := result.matchedTransfer.destination + knownATA = &dest + } + return verifyPostSettlement(ctx, caps, signature, network, requirements, publicKeysToStrings(f.signer.GetAddresses(ctx, network)), balanceBefore, knownATA) +} - if !progID.Equals(solana.ComputeBudget) { - return errors.New(ErrComputeLimitInstruction) +func hasStaticTransferLayout(tx *solana.Transaction) bool { + n := len(tx.Message.Instructions) + if n < 3 || n > 7 { + return false } - - // Check discriminator (should be 2 for SetComputeUnitLimit) - if len(inst.Data) < 1 || inst.Data[0] != 2 { - return errors.New(ErrComputeLimitInstruction) + transfer := tx.Message.Instructions[2] + programID, err := tx.Message.Program(transfer.ProgramIDIndex) + if err != nil { + return false + } + if !isTokenProgram(programID) { + return false } + return len(transfer.Data) >= 10 && transfer.Data[0] == ixTokenTransferChecked +} - // Decode to validate format - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) +// reconcilePendingSettlement handles a PendingSettlementStore cache hit: a +// prior Settle call for this transaction (keyed by txKey, the message hash) +// already broadcast sigStr but couldn't confirm it before returning +// settlement_pending. It re-awaits confirmation of that same signature rather +// than re-verifying/re-signing/re-sending — see the fast-path comment in +// Settle for why re-sending is unsafe here. +func (f *ExactSvmScheme) reconcilePendingSettlement( + ctx context.Context, + txKey string, + sigStr string, + payer string, + network x402.Network, + networkStr string, + isSmartWallet bool, + requirements types.PaymentRequirements, +) (*x402.SettleResponse, error) { + signature, err := solana.SignatureFromBase58(sigStr) if err != nil { + // Malformed cache entry — drop it so future attempts fall through to + // the normal broadcast path instead of getting stuck. + _ = f.pendingStore.Delete(ctx, txKey) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, payer, network, "", err.Error()) + } + + if err := f.signer.ConfirmTransaction(ctx, signature, networkStr); err != nil { + return nil, svm.RecordPendingOrTerminal(ctx, f.pendingStore, txKey, sigStr, payer, network, ErrTransactionFailed, err) + } + _ = f.pendingStore.Delete(ctx, txKey) + + if isSmartWallet { + caps, ok := f.signer.(svm.SmartWalletRPCCapabilities) + if !ok || !verifyPostSettlement(ctx, caps, signature, networkStr, requirements, publicKeysToStrings(f.signer.GetAddresses(ctx, networkStr)), nil, nil) { + return nil, x402.NewSettleError(ErrPostSettlementTransferNotConfirmed, payer, network, sigStr, ErrPostSettlementTransferNotConfirmed) + } + } + + return &x402.SettleResponse{ + Success: true, + Transaction: sigStr, + Network: network, + Payer: payer, + }, nil +} + +// verifyComputeLimitInstruction verifies the compute unit limit instruction +func (f *ExactSvmScheme) verifyComputeLimitInstruction(tx *solana.Transaction, inst solana.CompiledInstruction) error { + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil || !progID.Equals(solana.ComputeBudget) { return errors.New(ErrComputeLimitInstruction) } - _, err = computebudget.DecodeInstruction(accounts, inst.Data) - if err != nil { + // Check discriminator (should be 2 for SetComputeUnitLimit) + if len(inst.Data) < 5 || inst.Data[0] != ixSetComputeUnitLimit { return errors.New(ErrComputeLimitInstruction) } + units := binary.LittleEndian.Uint32(inst.Data[1:5]) + if f.config.MaxComputeUnits != nil && units > *f.config.MaxComputeUnits { + return errors.New(ErrComputeLimitInstructionTooHigh) + } + return nil } // verifyComputePriceInstruction verifies the compute unit price instruction func (f *ExactSvmScheme) verifyComputePriceInstruction(tx *solana.Transaction, inst solana.CompiledInstruction) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - if !progID.Equals(solana.ComputeBudget) { + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil || !progID.Equals(solana.ComputeBudget) { return errors.New(ErrComputePriceInstruction) } // Check discriminator (should be 3 for SetComputeUnitPrice) - if len(inst.Data) < 1 || inst.Data[0] != 3 { + if len(inst.Data) < 9 || inst.Data[0] != ixSetComputeUnitPrice { return errors.New(ErrComputePriceInstruction) } // Decode to get microLamports - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { - return errors.New(ErrComputePriceInstruction) + microLamports := binary.LittleEndian.Uint64(inst.Data[1:9]) + max := uint64(svm.MaxComputeUnitPriceMicrolamports) + if f.config.MaxPriorityFeeMicroLamports != nil { + max = *f.config.MaxPriorityFeeMicroLamports } - - decoded, err := computebudget.DecodeInstruction(accounts, inst.Data) - if err != nil { - return errors.New(ErrComputePriceInstruction) - } - // Check if it's SetComputeUnitPrice and validate the price - if priceInst, ok := decoded.Impl.(*computebudget.SetComputeUnitPrice); ok { + if microLamports > max { // Check if price exceeds maximum (5 lamports per compute unit = 5,000,000 microlamports) - if priceInst.MicroLamports > uint64(svm.MaxComputeUnitPriceMicrolamports) { - return errors.New(ErrComputePriceInstructionTooHigh) - } - } else { - return errors.New(ErrComputePriceInstruction) + return errors.New(ErrComputePriceInstructionTooHigh) } return nil @@ -403,7 +755,10 @@ func (f *ExactSvmScheme) verifyTransferInstruction( requirements x402.PaymentRequirements, signerAddresses []string, ) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil { + return errors.New(ErrNoTransferInstruction) + } // Must be Token Program or Token-2022 Program if progID != solana.TokenProgramID && progID != solana.Token2022ProgramID { @@ -455,7 +810,7 @@ func (f *ExactSvmScheme) verifyTransferInstruction( return errors.New(ErrMintMismatch) } - expectedDestATA, _, err := solana.FindAssociatedTokenAddress(payToPubkey, mintPubkey) + expectedDestATA, err := findATA(payToPubkey, mintPubkey, progID) if err != nil { return errors.New(ErrRecipientMismatch) } @@ -468,11 +823,11 @@ func (f *ExactSvmScheme) verifyTransferInstruction( // Verify amount requiredAmount, err := strconv.ParseUint(requirements.Amount, 10, 64) if err != nil { - return errors.New(ErrAmountInsufficient) + return errors.New(ErrAmountMismatch) } - if *transferChecked.Amount < requiredAmount { - return errors.New(ErrAmountInsufficient) + if *transferChecked.Amount != requiredAmount { + return errors.New(ErrAmountMismatch) } return nil diff --git a/go/mechanisms/svm/exact/facilitator/signature_verification.go b/go/mechanisms/svm/exact/facilitator/signature_verification.go new file mode 100644 index 0000000000..f07eec2227 --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/signature_verification.go @@ -0,0 +1,38 @@ +package facilitator + +import ( + "crypto/ed25519" + "errors" + + solana "github.com/gagliardetto/solana-go" +) + +// VerifyRequiredSignatures asserts account 0 is the advertised fee payer, then +// verifies Ed25519 signatures for signer indices 1..NumRequiredSignatures-1 +// over the marshaled message. Index 0 is skipped: the facilitator fills that +// slot at settle time. +func VerifyRequiredSignatures(tx *solana.Transaction, expectedFeePayer string) error { + if len(tx.Message.AccountKeys) == 0 || tx.Message.AccountKeys[0].String() != expectedFeePayer { + return errors.New(ErrFeePayerMismatch) + } + + messageBytes, err := tx.Message.MarshalBinary() + if err != nil { + return errors.New(ErrSignatureInvalid) + } + + numSigners := int(tx.Message.Header.NumRequiredSignatures) + for i := 1; i < numSigners; i++ { + if i >= len(tx.Message.AccountKeys) { + return errors.New(ErrSignatureInvalid) + } + if i >= len(tx.Signatures) || tx.Signatures[i].IsZero() { + return errors.New(ErrSignatureInvalid) + } + key := tx.Message.AccountKeys[i] + if !ed25519.Verify(ed25519.PublicKey(key.Bytes()), messageBytes, tx.Signatures[i][:]) { + return errors.New(ErrSignatureInvalid) + } + } + return nil +} diff --git a/go/mechanisms/svm/exact/facilitator/smart_wallet_test.go b/go/mechanisms/svm/exact/facilitator/smart_wallet_test.go new file mode 100644 index 0000000000..b44aa1373c --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/smart_wallet_test.go @@ -0,0 +1,387 @@ +package facilitator + +import ( + "context" + "encoding/binary" + "errors" + "testing" + + solana "github.com/gagliardetto/solana-go" + computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + "github.com/x402-foundation/x402/go/v2/types" +) + +var squadsV4 = solana.MustPublicKeyFromBase58("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf") + +type mockSmartWalletSigner struct { + mockExactSvmSigner + inner []rpc.InnerInstruction + simulateInnerErr error + confirmedInner []rpc.InnerInstruction + confirmedKeys solana.PublicKeySlice + confirmedErr error + tokenBalances map[string]uint64 + lookupTables map[solana.PublicKey]solana.PublicKeySlice + lookupErr error +} + +func (m *mockSmartWalletSigner) SimulateTransactionWithInnerInstructions(_ context.Context, _ *solana.Transaction, _ string) ([]rpc.InnerInstruction, error) { + if m.simulateInnerErr != nil { + return nil, m.simulateInnerErr + } + return m.inner, nil +} + +func (m *mockSmartWalletSigner) GetConfirmedTransactionInnerInstructions(_ context.Context, _ solana.Signature, _ string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + if m.confirmedErr != nil { + return nil, nil, m.confirmedErr + } + return m.confirmedInner, m.confirmedKeys, nil +} + +func (m *mockSmartWalletSigner) GetTokenAccountBalance(_ context.Context, tokenAccount solana.PublicKey, _ string) (uint64, bool, error) { + if m.tokenBalances == nil { + return 0, false, nil + } + bal, ok := m.tokenBalances[tokenAccount.String()] + return bal, ok, nil +} + +func (m *mockSmartWalletSigner) FetchAddressLookupTables(_ context.Context, tables []solana.PublicKey, _ string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + if m.lookupErr != nil { + return nil, m.lookupErr + } + if m.lookupTables != nil { + return m.lookupTables, nil + } + out := make(map[solana.PublicKey]solana.PublicKeySlice, len(tables)) + for _, table := range tables { + out[table] = nil + } + return out, nil +} + +var _ svm.SmartWalletRPCCapabilities = (*mockSmartWalletSigner)(nil) + +type smartWalletFixture struct { + payload types.PaymentPayload + requirements types.PaymentRequirements + facilitatorAddr solana.PublicKey + owner solana.PublicKey + tx *solana.Transaction + destATA solana.PublicKey + inner rpc.InnerInstruction +} + +func buildSmartWalletFixture(t *testing.T, walletProgram solana.PublicKey, cuLimit uint32) smartWalletFixture { + t.Helper() + + facilitatorAddr := solana.NewWallet().PublicKey() + ownerWallet := solana.NewWallet() + owner := ownerWallet.PublicKey() + mint := solana.NewWallet().PublicKey() + payTo := solana.NewWallet().PublicKey() + + sourceATA, _, err := solana.FindAssociatedTokenAddress(owner, mint) + require.NoError(t, err) + destATA, _, err := solana.FindAssociatedTokenAddress(payTo, mint) + require.NoError(t, err) + + limitIx, err := computebudget.NewSetComputeUnitLimitInstructionBuilder(). + SetUnits(cuLimit). + ValidateAndBuild() + require.NoError(t, err) + priceIx, err := computebudget.NewSetComputeUnitPriceInstructionBuilder(). + SetMicroLamports(1000). + ValidateAndBuild() + require.NoError(t, err) + + walletIx := solana.NewInstruction( + walletProgram, + solana.AccountMetaSlice{ + solana.NewAccountMeta(sourceATA, true, false), + solana.NewAccountMeta(mint, false, false), + solana.NewAccountMeta(destATA, true, false), + solana.NewAccountMeta(owner, true, true), + solana.NewAccountMeta(solana.TokenProgramID, false, false), + }, + []byte{0}, + ) + + blockhash, err := solana.HashFromBase58("5Tx8F3jgSHx21CbtjwmdaKPLM5tWmreWAnPrbqHomSJF") + require.NoError(t, err) + + tx, err := solana.NewTransactionBuilder(). + AddInstruction(limitIx). + AddInstruction(priceIx). + AddInstruction(walletIx). + SetRecentBlockHash(blockhash). + SetFeePayer(facilitatorAddr). + Build() + require.NoError(t, err) + signTransaction(t, tx, ownerWallet.PrivateKey) + + encoded, err := svm.EncodeTransaction(tx) + require.NoError(t, err) + + requirements := types.PaymentRequirements{ + Scheme: svm.SchemeExact, + Network: svm.SolanaDevnetCAIP2, + Asset: mint.String(), + Amount: "1000", + PayTo: payTo.String(), + Extra: map[string]interface{}{ + "feePayer": facilitatorAddr.String(), + }, + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: (&svm.ExactSvmPayload{Transaction: encoded}).ToMap(), + Accepted: requirements, + } + + mustIndex := func(key solana.PublicKey) uint16 { + idx, idxErr := tx.GetAccountIndex(key) + require.NoError(t, idxErr) + return idx + } + data := make([]byte, 10) + data[0] = ixTokenTransferChecked + binary.LittleEndian.PutUint64(data[1:9], 1000) + data[9] = 6 + inner := rpc.InnerInstruction{ + Index: 2, + Instructions: []rpc.CompiledInstruction{{ + ProgramIDIndex: mustIndex(solana.TokenProgramID), + Accounts: []uint16{ + mustIndex(sourceATA), + mustIndex(mint), + mustIndex(destATA), + mustIndex(owner), + }, + Data: solana.Base58(data), + }}, + } + + return smartWalletFixture{ + payload: payload, + requirements: requirements, + facilitatorAddr: facilitatorAddr, + owner: owner, + tx: tx, + destATA: destATA, + inner: inner, + } +} + +func TestExactSvmScheme_SmartWalletPath2Accepted(t *testing.T) { + f := buildSmartWalletFixture(t, squadsV4, 200_000) + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{ + addresses: []solana.PublicKey{f.facilitatorAddr}, + sendSignature: solana.SignatureFromBytes(append([]byte{7}, make([]byte, 63)...)), + }, + inner: []rpc.InnerInstruction{f.inner}, + confirmedInner: []rpc.InnerInstruction{f.inner}, + confirmedKeys: f.tx.Message.AccountKeys, + tokenBalances: map[string]uint64{f.destATA.String(): 1000}, + } + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + + resp, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, f.owner.String(), resp.Payer) + assert.Equal(t, 0, signer.signCalls) + assert.Equal(t, 0, signer.simulateCalls) + + settle, err := scheme.Settle(context.Background(), f.payload, f.requirements, nil) + require.NoError(t, err) + assert.True(t, settle.Success) + assert.Equal(t, 1, signer.signCalls) +} + +func TestExactSvmScheme_SmartWalletDisallowedProgramRejected(t *testing.T) { + f := buildSmartWalletFixture(t, solana.SystemProgramID, 200_000) + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}}, + inner: []rpc.InnerInstruction{f.inner}, + } + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + + _, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Contains(t, ve.InvalidReason, ErrSmartWalletProgramNotAllowed) +} + +func TestExactSvmScheme_SmartWalletComputeUnitsTooHigh(t *testing.T) { + f := buildSmartWalletFixture(t, squadsV4, 500_000) + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}}, + inner: []rpc.InnerInstruction{f.inner}, + } + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + + _, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Contains(t, ve.InvalidReason, ErrSmartWalletComputeUnitsTooHigh) +} + +func TestExactSvmScheme_SmartWalletFeePayerIsolationRebuilt(t *testing.T) { + facilitatorAddr := solana.NewWallet().PublicKey() + ownerWallet := solana.NewWallet() + owner := ownerWallet.PublicKey() + mint := solana.NewWallet().PublicKey() + payTo := solana.NewWallet().PublicKey() + sourceATA, _, err := solana.FindAssociatedTokenAddress(owner, mint) + require.NoError(t, err) + destATA, _, err := solana.FindAssociatedTokenAddress(payTo, mint) + require.NoError(t, err) + + limitIx, err := computebudget.NewSetComputeUnitLimitInstructionBuilder().SetUnits(200_000).ValidateAndBuild() + require.NoError(t, err) + priceIx, err := computebudget.NewSetComputeUnitPriceInstructionBuilder().SetMicroLamports(1000).ValidateAndBuild() + require.NoError(t, err) + walletIx := solana.NewInstruction( + squadsV4, + solana.AccountMetaSlice{ + solana.NewAccountMeta(facilitatorAddr, false, false), + solana.NewAccountMeta(sourceATA, true, false), + solana.NewAccountMeta(mint, false, false), + solana.NewAccountMeta(destATA, true, false), + solana.NewAccountMeta(owner, true, true), + }, + []byte{0}, + ) + blockhash, err := solana.HashFromBase58("5Tx8F3jgSHx21CbtjwmdaKPLM5tWmreWAnPrbqHomSJF") + require.NoError(t, err) + tx, err := solana.NewTransactionBuilder(). + AddInstruction(limitIx). + AddInstruction(priceIx). + AddInstruction(walletIx). + SetRecentBlockHash(blockhash). + SetFeePayer(facilitatorAddr). + Build() + require.NoError(t, err) + signTransaction(t, tx, ownerWallet.PrivateKey) + encoded, err := svm.EncodeTransaction(tx) + require.NoError(t, err) + + requirements := types.PaymentRequirements{ + Scheme: svm.SchemeExact, + Network: svm.SolanaDevnetCAIP2, + Asset: mint.String(), + Amount: "1000", + PayTo: payTo.String(), + Extra: map[string]interface{}{"feePayer": facilitatorAddr.String()}, + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: (&svm.ExactSvmPayload{Transaction: encoded}).ToMap(), + Accepted: requirements, + } + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}}, + } + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + _, verr := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, verr) + require.True(t, errors.As(verr, &ve)) + assert.Contains(t, ve.InvalidReason, ErrSmartWalletFeePayerNotIsolated) +} + +func TestExactSvmScheme_SmartWalletPriorityFeeTooHigh(t *testing.T) { + f := buildSmartWalletFixture(t, squadsV4, 200_000) + max := uint64(500) + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}}, + inner: []rpc.InnerInstruction{f.inner}, + } + scheme := NewExactSvmScheme(signer, &Config{ + EnableSmartWalletVerification: true, + SmartWalletMaxPriorityFeeMicroLamports: &max, + }) + + _, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Contains(t, ve.InvalidReason, ErrSmartWalletPriorityFeeTooHigh) +} + +func TestExactSvmScheme_LookupTableResolvedWhenSignerSupportsIt(t *testing.T) { + f := buildExactFixture(t) + table := solana.NewWallet().PublicKey() + f.tx.Message.SetVersion(solana.MessageVersionV0) + f.tx.Message.AddressTableLookups = solana.MessageAddressTableLookupSlice{{ + AccountKey: table, + }} + require.NoError(t, f.tx.Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{table: {}})) + signTransaction(t, f.tx, f.ownerKey) + encoded, err := svm.EncodeTransaction(f.tx) + require.NoError(t, err) + f.payload.Payload = (&svm.ExactSvmPayload{Transaction: encoded}).ToMap() + + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}}, + lookupTables: map[solana.PublicKey]solana.PublicKeySlice{table: {}}, + } + scheme := NewExactSvmScheme(signer) + + resp, verr := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + require.NoError(t, verr) + assert.True(t, resp.IsValid) +} + +func TestBalanceDeltaMeetsAmount(t *testing.T) { + tests := []struct { + name string + after uint64 + before uint64 + required uint64 + want bool + }{ + {name: "sufficient increase", after: 2000, before: 1000, required: 1000, want: true}, + {name: "increase exceeds required", after: 2500, before: 1000, required: 1000, want: true}, + {name: "unchanged balance", after: 1000, before: 1000, required: 1000, want: false}, + {name: "insufficient increase", after: 1500, before: 1000, required: 1000, want: false}, + {name: "balance decrease does not wrap", after: 900, before: 1000, required: 1000, want: false}, + {name: "zero after lower than before", after: 0, before: 1, required: 1, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, balanceDeltaMeetsAmount(tt.after, tt.before, tt.required)) + }) + } +} + +func TestExactSvmScheme_PostSettlementTOCTOUMiss(t *testing.T) { + f := buildSmartWalletFixture(t, squadsV4, 200_000) + signer := &mockSmartWalletSigner{ + mockExactSvmSigner: mockExactSvmSigner{ + addresses: []solana.PublicKey{f.facilitatorAddr}, + sendSignature: solana.SignatureFromBytes(append([]byte{8}, make([]byte, 63)...)), + }, + inner: []rpc.InnerInstruction{f.inner}, + confirmedInner: []rpc.InnerInstruction{}, + confirmedKeys: f.tx.Message.AccountKeys, + tokenBalances: map[string]uint64{f.destATA.String(): 0}, + } + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + + _, err := scheme.Settle(context.Background(), f.payload, f.requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrPostSettlementTransferNotConfirmed, se.ErrorReason) +} diff --git a/go/mechanisms/svm/exact/facilitator/smart_wallet_verification.go b/go/mechanisms/svm/exact/facilitator/smart_wallet_verification.go new file mode 100644 index 0000000000..d3dfdcbd92 --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/smart_wallet_verification.go @@ -0,0 +1,413 @@ +package facilitator + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "strconv" + "time" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + defaultSmartWalletMaxComputeUnits uint32 = 400_000 + defaultSmartWalletMaxPriorityFeeMicroLamports uint64 = 50_000 + + ixSetComputeUnitLimit = 2 + ixSetComputeUnitPrice = 3 + ixTokenTransferChecked = 12 + + postSettlementInnerRetries = 3 +) + +var defaultSmartWalletAllowedPrograms = []string{ + "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf", // Squads Multisig v4 + "SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG", // Squads Smart Account + "SWiGmQedKzMz1tiTqoJCWeGDnGXfNBp2PkXLkpCAtQo", // Swig (legacy) + "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB", // Swig v2 + "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw", // SPL Governance + "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d", // Metaplex Core + svm.LighthouseProgramAddress, // Phantom wallet-protection assertions +} + +type transferCheckedInfo struct { + programID solana.PublicKey + amount uint64 + mint solana.PublicKey + destination solana.PublicKey + authority solana.PublicKey +} + +func isTokenProgram(programID solana.PublicKey) bool { + return programID.Equals(solana.TokenProgramID) || programID.Equals(solana.Token2022ProgramID) +} + +func findATA(owner, mint, tokenProgram solana.PublicKey) (solana.PublicKey, error) { + ata, _, err := solana.FindProgramAddress( + [][]byte{owner.Bytes(), tokenProgram.Bytes(), mint.Bytes()}, + solana.SPLAssociatedTokenAccountProgramID, + ) + return ata, err +} + +func expectedDestinationATAs(payTo, mint solana.PublicKey) (map[solana.PublicKey]struct{}, error) { + atas := make(map[solana.PublicKey]struct{}, 2) + for _, program := range []solana.PublicKey{solana.TokenProgramID, solana.Token2022ProgramID} { + ata, err := findATA(payTo, mint, program) + if err != nil { + continue + } + atas[ata] = struct{}{} + } + if len(atas) == 0 { + return nil, errors.New(ErrSmartWalletCannotDeriveATA) + } + return atas, nil +} + +func assertFeePayerIsolated(tx *solana.Transaction, feePayer solana.PublicKey) error { + for _, ix := range tx.Message.Instructions { + programID, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + return fmt.Errorf("%s: %w", ErrSmartWalletFeePayerNotIsolated, err) + } + if programID.Equals(feePayer) { + return fmt.Errorf("%s: fee payer %s invoked as program", ErrSmartWalletFeePayerNotIsolated, feePayer) + } + for _, idx := range ix.Accounts { + account, err := tx.Message.Account(idx) + if err != nil { + return fmt.Errorf("%s: %w", ErrSmartWalletFeePayerNotIsolated, err) + } + if account.Equals(feePayer) { + return fmt.Errorf("%s: fee payer %s appears in instruction accounts (program: %s)", + ErrSmartWalletFeePayerNotIsolated, feePayer, programID) + } + } + } + return nil +} + +func validateComputeBudgetLimits(tx *solana.Transaction, maxCU uint32, maxPriorityFee uint64) error { + for _, ix := range tx.Message.Instructions { + programID, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil || !programID.Equals(solana.ComputeBudget) { + continue + } + if len(ix.Data) == 0 { + return errors.New(ErrSmartWalletMalformedComputeBudget) + } + switch ix.Data[0] { + case ixSetComputeUnitLimit: + if len(ix.Data) < 5 { + return errors.New(ErrSmartWalletMalformedComputeLimit) + } + units := binary.LittleEndian.Uint32(ix.Data[1:5]) + if units > maxCU { + return fmt.Errorf("%s: %d exceeds max %d", ErrSmartWalletComputeUnitsTooHigh, units, maxCU) + } + case ixSetComputeUnitPrice: + if len(ix.Data) < 9 { + return errors.New(ErrSmartWalletMalformedComputePrice) + } + microLamports := binary.LittleEndian.Uint64(ix.Data[1:9]) + if microLamports > maxPriorityFee { + return fmt.Errorf("%s: %d exceeds max %d", ErrSmartWalletPriorityFeeTooHigh, microLamports, maxPriorityFee) + } + default: + return fmt.Errorf("%s: type %d", ErrSmartWalletUnsupportedComputeBudget, ix.Data[0]) + } + } + return nil +} + +func extractTransfersFromInnerInstructions(inner []rpc.InnerInstruction, accountKeys solana.PublicKeySlice) []transferCheckedInfo { + if inner == nil { + return nil + } + var transfers []transferCheckedInfo + for _, group := range inner { + for _, ix := range group.Instructions { + if int(ix.ProgramIDIndex) >= len(accountKeys) { + continue + } + programID := accountKeys[ix.ProgramIDIndex] + if !isTokenProgram(programID) { + continue + } + if len(ix.Data) < 9 || ix.Data[0] != ixTokenTransferChecked || len(ix.Accounts) < 4 { + continue + } + mintIdx, destIdx, authIdx := ix.Accounts[1], ix.Accounts[2], ix.Accounts[3] + if int(mintIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) { + continue + } + transfers = append(transfers, transferCheckedInfo{ + programID: programID, + amount: binary.LittleEndian.Uint64(ix.Data[1:9]), + mint: accountKeys[mintIdx], + destination: accountKeys[destIdx], + authority: accountKeys[authIdx], + }) + } + } + return transfers +} + +func extractTopLevelTransfers(tx *solana.Transaction) []transferCheckedInfo { + var transfers []transferCheckedInfo + for _, ix := range tx.Message.Instructions { + programID, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil || !isTokenProgram(programID) { + continue + } + if len(ix.Data) < 9 || ix.Data[0] != ixTokenTransferChecked || len(ix.Accounts) < 4 { + continue + } + mint, err := tx.Message.Account(ix.Accounts[1]) + if err != nil { + continue + } + destination, err := tx.Message.Account(ix.Accounts[2]) + if err != nil { + continue + } + authority, err := tx.Message.Account(ix.Accounts[3]) + if err != nil { + continue + } + transfers = append(transfers, transferCheckedInfo{ + programID: programID, + amount: binary.LittleEndian.Uint64(ix.Data[1:9]), + mint: mint, + destination: destination, + authority: authority, + }) + } + return transfers +} + +func verifySmartWalletMemo(tx *solana.Transaction, expectedMemo string) error { + if expectedMemo == "" { + return nil + } + memoPubkey := solana.MustPublicKeyFromBase58(svm.MemoProgramAddress) + var memoCount int + var actual []byte + for _, ix := range tx.Message.Instructions { + programID, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + continue + } + if programID.Equals(memoPubkey) { + memoCount++ + actual = ix.Data + } + } + if memoCount != 1 { + return errors.New(ErrMemoCount) + } + if string(actual) != expectedMemo { + return errors.New(ErrMemoMismatch) + } + return nil +} + +func verifySmartWalletTransaction( + ctx context.Context, + tx *solana.Transaction, + requirements types.PaymentRequirements, + caps svm.SmartWalletRPCCapabilities, + feePayer solana.PublicKey, + signerAddresses []string, + maxCU uint32, + maxPriorityFee uint64, +) (*transferCheckedInfo, error) { + if err := assertFeePayerIsolated(tx, feePayer); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + if err := validateComputeBudgetLimits(tx, maxCU, maxPriorityFee); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + inner, err := caps.SimulateTransactionWithInnerInstructions(ctx, tx, requirements.Network) + if err != nil { + return nil, x402.NewVerifyError( + fmt.Sprintf("%s: %s", ErrSmartWalletSimulationFailed, err.Error()), + "", + err.Error(), + ) + } + + expectedMemo, _ := requirements.Extra["memo"].(string) + if err := verifySmartWalletMemo(tx, expectedMemo); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + transfers := extractTopLevelTransfers(tx) + transfers = append(transfers, extractTransfersFromInnerInstructions(inner, tx.Message.AccountKeys)...) + + signerSet := make(map[string]struct{}, len(signerAddresses)) + for _, addr := range signerAddresses { + signerSet[addr] = struct{}{} + } + for _, t := range transfers { + if _, ok := signerSet[t.authority.String()]; ok { + return nil, x402.NewVerifyError(ErrFeePayerTransferringFunds, t.authority.String(), ErrFeePayerTransferringFunds) + } + } + + payTo, err := solana.PublicKeyFromBase58(requirements.PayTo) + if err != nil { + return nil, x402.NewVerifyError(ErrSmartWalletCannotDeriveATA, "", err.Error()) + } + mint, err := solana.PublicKeyFromBase58(requirements.Asset) + if err != nil { + return nil, x402.NewVerifyError(ErrSmartWalletCannotDeriveATA, "", err.Error()) + } + expectedATAs, err := expectedDestinationATAs(payTo, mint) + if err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + requiredAmount, err := parseUintAmount(requirements.Amount) + if err != nil { + return nil, x402.NewVerifyError(ErrSmartWalletTransferMismatch, "", err.Error()) + } + + var matching []transferCheckedInfo + for _, t := range transfers { + if t.mint.String() != requirements.Asset { + continue + } + if _, ok := expectedATAs[t.destination]; !ok { + continue + } + if t.amount >= requiredAmount { + matching = append(matching, t) + } + } + + if len(matching) == 0 { + if len(transfers) == 0 { + return nil, x402.NewVerifyError(ErrSmartWalletNoTransferInSimulation, "", ErrSmartWalletNoTransferInSimulation) + } + return nil, x402.NewVerifyError(ErrSmartWalletTransferMismatch, transfers[0].authority.String(), ErrSmartWalletTransferMismatch) + } + if len(matching) > 1 { + return nil, x402.NewVerifyError(ErrSmartWalletMultipleMatchingTransfers, matching[0].authority.String(), ErrSmartWalletMultipleMatchingTransfers) + } + return &matching[0], nil +} + +func parseUintAmount(amount string) (uint64, error) { + return strconv.ParseUint(amount, 10, 64) +} + +func balanceDeltaMeetsAmount(after, before, required uint64) bool { + return after >= before && after-before >= required +} + +func verifyPostSettlement( + ctx context.Context, + caps svm.SmartWalletRPCCapabilities, + signature solana.Signature, + network string, + requirements types.PaymentRequirements, + signerAddresses []string, + balanceBefore *uint64, + knownDestinationATA *solana.PublicKey, +) bool { + requiredAmount, err := parseUintAmount(requirements.Amount) + if err != nil { + return false + } + + signerSet := make(map[string]struct{}, len(signerAddresses)) + for _, addr := range signerAddresses { + signerSet[addr] = struct{}{} + } + + var lastInner []rpc.InnerInstruction + var lastKeys solana.PublicKeySlice + for attempt := 0; attempt < postSettlementInnerRetries; attempt++ { + inner, keys, fetchErr := caps.GetConfirmedTransactionInnerInstructions(ctx, signature, network) + if fetchErr == nil && inner != nil { + lastInner, lastKeys = inner, keys + break + } + if attempt < postSettlementInnerRetries-1 { + time.Sleep(time.Duration(100*(attempt+1)) * time.Millisecond) + } + } + + if lastInner != nil { + expectedATAs := map[solana.PublicKey]struct{}{} + if knownDestinationATA != nil { + expectedATAs[*knownDestinationATA] = struct{}{} + } else if payTo, payToErr := solana.PublicKeyFromBase58(requirements.PayTo); payToErr == nil { + if mint, mintErr := solana.PublicKeyFromBase58(requirements.Asset); mintErr == nil { + if atas, ataErr := expectedDestinationATAs(payTo, mint); ataErr == nil { + expectedATAs = atas + } + } + } + for _, t := range extractTransfersFromInnerInstructions(lastInner, lastKeys) { + if t.mint.String() != requirements.Asset { + continue + } + if _, ok := expectedATAs[t.destination]; !ok { + continue + } + if t.amount < requiredAmount { + continue + } + if _, self := signerSet[t.authority.String()]; self { + continue + } + return true + } + return false + } + + if balanceBefore == nil { + return false + } + if knownDestinationATA != nil { + after, exists, balErr := caps.GetTokenAccountBalance(ctx, *knownDestinationATA, network) + if balErr != nil || !exists { + return false + } + return balanceDeltaMeetsAmount(after, *balanceBefore, requiredAmount) + } + + payTo, err := solana.PublicKeyFromBase58(requirements.PayTo) + if err != nil { + return false + } + mint, err := solana.PublicKeyFromBase58(requirements.Asset) + if err != nil { + return false + } + for _, program := range []solana.PublicKey{solana.TokenProgramID, solana.Token2022ProgramID} { + ata, ataErr := findATA(payTo, mint, program) + if ataErr != nil { + continue + } + after, exists, balErr := caps.GetTokenAccountBalance(ctx, ata, network) + if balErr != nil || !exists { + continue + } + if balanceDeltaMeetsAmount(after, *balanceBefore, requiredAmount) { + return true + } + } + return false +} diff --git a/go/mechanisms/svm/exact/facilitator/verify_test.go b/go/mechanisms/svm/exact/facilitator/verify_test.go new file mode 100644 index 0000000000..ea135714d8 --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/verify_test.go @@ -0,0 +1,229 @@ +package facilitator + +import ( + "context" + "errors" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" +) + +func TestVerifyRequiredSignatures_RejectsFeePayerMismatch(t *testing.T) { + f := buildExactFixture(t) + other := solana.NewWallet().PublicKey() + err := VerifyRequiredSignatures(f.tx, other.String()) + require.Error(t, err) + assert.Equal(t, ErrFeePayerMismatch, err.Error()) +} + +func TestVerifyRequiredSignatures_RejectsForgedClientSignature(t *testing.T) { + f := buildExactFixture(t) + forged := solana.Signature{} + for i := range forged { + forged[i] = byte(i + 1) + } + f.tx.Signatures[1] = forged + err := VerifyRequiredSignatures(f.tx, f.facilitatorAddr.String()) + require.Error(t, err) + assert.Equal(t, ErrSignatureInvalid, err.Error()) +} + +func TestVerifyRequiredSignatures_RejectsMissingClientSignature(t *testing.T) { + f := buildExactFixture(t) + f.tx.Signatures[1] = solana.Signature{} + err := VerifyRequiredSignatures(f.tx, f.facilitatorAddr.String()) + require.Error(t, err) + assert.Equal(t, ErrSignatureInvalid, err.Error()) +} + +func TestVerifyRequiredSignatures_AcceptsUnsignedFeePayerSlot(t *testing.T) { + f := buildExactFixture(t) + require.True(t, f.tx.Signatures[0].IsZero()) + require.NoError(t, VerifyRequiredSignatures(f.tx, f.facilitatorAddr.String())) +} + +func TestExactSvmScheme_VerifyNeverSigns(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + + resp, err := scheme.Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, 0, signer.signCalls) + assert.Equal(t, 1, signer.simulateCalls) +} + +func TestExactSvmScheme_SettleSignsOnce(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + signer := &mockExactSvmSigner{ + addresses: []solana.PublicKey{facilitatorAddr}, + sendSignature: solana.SignatureFromBytes(append([]byte{9}, make([]byte, 63)...)), + } + scheme := NewExactSvmScheme(signer) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 1, signer.signCalls) +} + +func TestExactSvmScheme_DuplicateSettleReturnsBeforeVerification(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + txKey := messageHashForPayload(t, payload) + assert.False(t, scheme.settlementCache.IsDuplicate(txKey)) + + _, err := scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrDuplicateSettlement, se.ErrorReason) + assert.NotEmpty(t, se.Payer) + assert.Equal(t, 0, signer.signCalls) + assert.Equal(t, 0, signer.simulateCalls) + assert.Equal(t, 0, signer.sendCalls) +} + +func TestExactSvmScheme_VerifyRejectsFeePayerMismatch(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + other := solana.NewWallet().PublicKey() + requirements.Extra["feePayer"] = other.String() + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr, other}} + scheme := NewExactSvmScheme(signer) + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Equal(t, ErrFeePayerMismatch, ve.InvalidReason) + assert.Equal(t, 0, signer.signCalls) +} + +func TestExactSvmScheme_MaxRequiredSignaturesRejected(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + maxSigs := uint8(1) + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer, &Config{MaxRequiredSignatures: &maxSigs}) + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Equal(t, ErrExcessiveSigners, ve.InvalidReason) +} + +func TestExactSvmScheme_ConfigPanicsOnZeroLimits(t *testing.T) { + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{solana.NewWallet().PublicKey()}} + zero := uint32(0) + assert.Panics(t, func() { + NewExactSvmScheme(signer, &Config{MaxComputeUnits: &zero}) + }) + zeroSigs := uint8(0) + assert.Panics(t, func() { + NewExactSvmScheme(signer, &Config{MaxRequiredSignatures: &zeroSigs}) + }) +} + +func TestExactSvmScheme_ConfigPanicsWhenSmartWalletEnabledWithoutCapabilities(t *testing.T) { + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{solana.NewWallet().PublicKey()}} + assert.Panics(t, func() { + NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + }) +} + +func TestExactSvmScheme_GetExtraFeaturesFlag(t *testing.T) { + addr := solana.NewWallet().PublicKey() + signer := &mockSmartWalletSigner{mockExactSvmSigner: mockExactSvmSigner{addresses: []solana.PublicKey{addr}}} + scheme := NewExactSvmScheme(signer, &Config{EnableSmartWalletVerification: true}) + + extra := scheme.GetExtra(x402.Network(svm.SolanaDevnetCAIP2)) + features, ok := extra["features"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, features["smartWalletSupported"]) +} + +func TestExactSvmScheme_LookupTableRejectedWithoutCapabilities(t *testing.T) { + f := buildExactFixture(t) + table := solana.NewWallet().PublicKey() + f.tx.Message.SetVersion(solana.MessageVersionV0) + f.tx.Message.AddressTableLookups = solana.MessageAddressTableLookupSlice{{ + AccountKey: table, + }} + require.NoError(t, f.tx.Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{table: {}})) + signTransaction(t, f.tx, f.ownerKey) + encoded, err := svm.EncodeTransaction(f.tx) + require.NoError(t, err) + f.payload.Payload = (&svm.ExactSvmPayload{Transaction: encoded}).ToMap() + + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + _, verr := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + var ve *x402.VerifyError + require.Error(t, verr) + require.True(t, errors.As(verr, &ve)) + assert.Contains(t, ve.InvalidReason, ErrSmartWalletAltResolutionUnavailable) +} + +func TestExactSvmScheme_Path1AcceptsSevenLighthouseInstructions(t *testing.T) { + f := buildExactFixtureWithOptional(t, + lighthouseInstruction(), + lighthouseInstruction(), + lighthouseInstruction(), + lighthouseInstruction(), + ) + require.Equal(t, 7, len(f.tx.Message.Instructions)) + + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + resp, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) +} + +func TestExactSvmScheme_Path1RejectsEightInstructions(t *testing.T) { + f := buildExactFixtureWithOptional(t, + lighthouseInstruction(), + lighthouseInstruction(), + lighthouseInstruction(), + lighthouseInstruction(), + lighthouseInstruction(), + ) + require.Equal(t, 8, len(f.tx.Message.Instructions)) + + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{f.facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + _, err := scheme.Verify(context.Background(), f.payload, f.requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Equal(t, ErrTransactionInstructionsLength, ve.InvalidReason) +} + +func TestExactSvmScheme_Path1RejectsOverpayment(t *testing.T) { + payload, requirements, facilitatorAddr := buildValidExactSvmFixture(t) + requirements.Amount = "500" + signer := &mockExactSvmSigner{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmScheme(signer) + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Equal(t, ErrAmountMismatch, ve.InvalidReason) + assert.Equal(t, 0, signer.signCalls) +} + +func TestGetTokenPayerFromTransaction_LookupProgramIndexDoesNotPanic(t *testing.T) { + f := buildExactFixture(t) + f.tx.Message.Instructions[2].ProgramIDIndex = uint16(len(f.tx.Message.AccountKeys) + 5) + require.NotPanics(t, func() { + _, err := svm.GetTokenPayerFromTransaction(f.tx) + require.Error(t, err) + }) +} diff --git a/go/mechanisms/svm/exact/server/scheme.go b/go/mechanisms/svm/exact/server/scheme.go index 6e320b2a68..b40d4985c3 100644 --- a/go/mechanisms/svm/exact/server/scheme.go +++ b/go/mechanisms/svm/exact/server/scheme.go @@ -60,7 +60,7 @@ func (s *ExactSvmScheme) DynamicExtraFields() []string { func (s *ExactSvmScheme) PaymentFlows() map[string]x402.PaymentFlowConfig { return map[string]x402.PaymentFlowConfig{ x402.SDKDefaultAssetTransferMethod: { - Supported: []x402.PaymentFlowName{x402.PaymentFlowAuthorization}, + Supported: []x402.PaymentFlowName{x402.PaymentFlowAuthorization, x402.PaymentFlowUpfront}, Default: x402.PaymentFlowAuthorization, }, } diff --git a/go/mechanisms/svm/exact/server/scheme_test.go b/go/mechanisms/svm/exact/server/scheme_test.go new file mode 100644 index 0000000000..a8592a0ab4 --- /dev/null +++ b/go/mechanisms/svm/exact/server/scheme_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + x402 "github.com/x402-foundation/x402/go/v2" +) + +func TestPaymentFlowsDeclareAuthorizationAndUpfront(t *testing.T) { + scheme := NewExactSvmScheme() + flows := scheme.PaymentFlows() + + require.Contains(t, flows, x402.SDKDefaultAssetTransferMethod) + assert.Equal(t, x402.SDKDefaultAssetTransferMethod, scheme.DefaultAssetTransferMethod()) + assert.Equal(t, []x402.PaymentFlowName{x402.PaymentFlowAuthorization, x402.PaymentFlowUpfront}, flows[x402.SDKDefaultAssetTransferMethod].Supported) + assert.Equal(t, x402.PaymentFlowAuthorization, flows[x402.SDKDefaultAssetTransferMethod].Default) +} diff --git a/go/mechanisms/svm/exact/v1/facilitator/errors.go b/go/mechanisms/svm/exact/v1/facilitator/errors.go index 0f4852b186..e47ce1d154 100644 --- a/go/mechanisms/svm/exact/v1/facilitator/errors.go +++ b/go/mechanisms/svm/exact/v1/facilitator/errors.go @@ -10,6 +10,7 @@ const ( ErrFeePayerNotManaged = "invalid_exact_solana_fee_payer_not_managed_by_facilitator" ErrInvalidPayloadTransaction = "invalid_exact_solana_payload_transaction" ErrTransactionCouldNotBeDecoded = "invalid_exact_solana_payload_transaction_could_not_be_decoded" + ErrSignatureInvalid = "invalid_exact_solana_payload_signature_invalid" ErrTransactionInstructionsLength = "invalid_exact_solana_payload_transaction_instructions_length" ErrUnknownFourthInstruction = "invalid_exact_solana_payload_unknown_fourth_instruction" ErrUnknownFifthInstruction = "invalid_exact_solana_payload_unknown_fifth_instruction" @@ -21,7 +22,7 @@ const ( ErrFeePayerTransferringFunds = "invalid_exact_solana_payload_transaction_fee_payer_transferring_funds" ErrMintMismatch = "invalid_exact_solana_payload_mint_mismatch" ErrRecipientMismatch = "invalid_exact_solana_payload_recipient_mismatch" - ErrAmountInsufficient = "invalid_exact_solana_payload_amount_insufficient" + ErrAmountMismatch = "invalid_exact_solana_payload_amount_mismatch" ErrInvalidFeePayer = "invalid_exact_solana_invalid_fee_payer" ErrTransactionSigningFailed = "invalid_exact_solana_transaction_signing_failed" ErrTransactionSimulationFailed = "invalid_exact_solana_transaction_simulation_failed" diff --git a/go/mechanisms/svm/exact/v1/facilitator/scheme.go b/go/mechanisms/svm/exact/v1/facilitator/scheme.go index ad917757ba..014214c936 100644 --- a/go/mechanisms/svm/exact/v1/facilitator/scheme.go +++ b/go/mechanisms/svm/exact/v1/facilitator/scheme.go @@ -2,6 +2,7 @@ package facilitator import ( "context" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -9,11 +10,11 @@ import ( "strconv" solana "github.com/gagliardetto/solana-go" - computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" "github.com/gagliardetto/solana-go/programs/token" x402 "github.com/x402-foundation/x402/go/v2" svm "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + exactv2 "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/facilitator" "github.com/x402-foundation/x402/go/v2/types" ) @@ -141,6 +142,14 @@ func (f *ExactSvmSchemeV1) Verify( return nil, x402.NewVerifyError(ErrTransactionCouldNotBeDecoded, "", err.Error()) } + if err := exactv2.VerifyRequiredSignatures(tx, feePayerStr); err != nil { + return nil, x402.NewVerifyError(err.Error(), "", err.Error()) + } + + if len(tx.Message.GetAddressTableLookups()) > 0 { + return nil, x402.NewVerifyError(ErrTransactionCouldNotBeDecoded, "", "transaction uses Address Lookup Tables") + } + // Allow 3-6 instructions: // - 3 instructions: ComputeLimit + ComputePrice + TransferChecked // - 4 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse or Memo @@ -185,7 +194,14 @@ func (f *ExactSvmSchemeV1) Verify( } for i, instruction := range optionalInstructions { - progID := tx.Message.AccountKeys[instruction.ProgramIDIndex] + progID, progErr := tx.Message.Program(instruction.ProgramIDIndex) + if progErr != nil { + reason := ErrUnknownSixthInstruction + if i < len(invalidReasons) { + reason = invalidReasons[i] + } + return nil, x402.NewVerifyError(reason, payer, progErr.Error()) + } if progID.Equals(lighthousePubkey) || progID.Equals(memoPubkey) { continue } @@ -203,7 +219,10 @@ func (f *ExactSvmSchemeV1) Verify( var memoCount int var actualMemoData []byte for _, instruction := range optionalInstructions { - progID := tx.Message.AccountKeys[instruction.ProgramIDIndex] + progID, progErr := tx.Message.Program(instruction.ProgramIDIndex) + if progErr != nil { + continue + } if progID.Equals(memoPubkey) { memoCount++ actualMemoData = instruction.Data @@ -218,21 +237,9 @@ func (f *ExactSvmSchemeV1) Verify( } } - // Step 6: Sign and Simulate Transaction + // Step 6: Simulate Transaction // CRITICAL: Simulation proves transaction will succeed (catches insufficient balance, invalid accounts, etc) - - // feePayer already validated in Step 1 - feePayer, err := solana.PublicKeyFromBase58(feePayerStr) - if err != nil { - return nil, x402.NewVerifyError(ErrInvalidFeePayer, payer, err.Error()) - } - - // Sign transaction with the feePayer's signer - if err := f.signer.SignTransaction(ctx, tx, feePayer, string(requirements.Network)); err != nil { - return nil, x402.NewVerifyError(ErrTransactionSigningFailed, payer, err.Error()) - } - - // Simulate transaction to verify it would succeed + // Signatures are verified locally; the fee-payer slot is unsigned until settle. if err := f.signer.SimulateTransaction(ctx, tx, string(requirements.Network)); err != nil { return nil, x402.NewVerifyError(ErrTransactionSimulationFailed, payer, err.Error()) } @@ -253,66 +260,62 @@ func (f *ExactSvmSchemeV1) Settle( ) (*x402.SettleResponse, error) { network := x402.Network(payload.Network) - // First verify the payment - verifyResp, err := f.Verify(ctx, payload, requirements, fctx) - if err != nil { - // Convert VerifyError to SettleError - ve := &x402.VerifyError{} - if errors.As(err, &ve) { - return nil, x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) - } - return nil, x402.NewSettleError(ErrVerificationFailed, "", network, "", err.Error()) - } - // Parse payload svmPayload, err := svm.PayloadFromMap(payload.Payload) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) } - // Decode transaction before the cache check so we can key on the message hash. tx, err := svm.DecodeTransaction(svmPayload.Transaction) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) } - - // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). txKey, err := svm.MessageHash(tx) if err != nil { - return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, verifyResp.Payer, network, "", err.Error()) + return nil, x402.NewSettleError(ErrInvalidPayloadTransaction, "", network, "", err.Error()) } + // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). if f.settlementCache.IsDuplicate(txKey) { - return nil, x402.NewSettleError(ErrDuplicateSettlement, verifyResp.Payer, network, "", "duplicate transaction") + payer, _ := svm.GetTokenPayerFromTransaction(tx) + return nil, x402.NewSettleError(ErrDuplicateSettlement, payer, network, "", "duplicate transaction") + } + + // First verify the payment + verifyResp, err := f.Verify(ctx, payload, requirements, fctx) + if err != nil { + f.settlementCache.Delete(txKey) + // Convert VerifyError to SettleError + ve := &x402.VerifyError{} + if errors.As(err, &ve) { + return nil, x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) + } + return nil, x402.NewSettleError(ErrVerificationFailed, "", network, "", err.Error()) } // Parse extra field for feePayer (V1 uses *json.RawMessage) var reqExtraMap map[string]interface{} if requirements.Extra != nil { if err := json.Unmarshal(*requirements.Extra, &reqExtraMap); err != nil { + f.settlementCache.Delete(txKey) return nil, x402.NewSettleError(ErrInvalidExtraField, verifyResp.Payer, network, "", err.Error()) } } - // Extract and validate feePayer from requirements matches transaction feePayerStr, ok := reqExtraMap["feePayer"].(string) if !ok { + f.settlementCache.Delete(txKey) return nil, x402.NewSettleError(ErrMissingFeePayer, verifyResp.Payer, network, "", "") } expectedFeePayer, err := solana.PublicKeyFromBase58(feePayerStr) if err != nil { + f.settlementCache.Delete(txKey) return nil, x402.NewSettleError(ErrInvalidFeePayer, verifyResp.Payer, network, "", err.Error()) } - // Verify transaction feePayer matches requirements - actualFeePayer := tx.Message.AccountKeys[0] // First account is fee payer - if actualFeePayer != expectedFeePayer { - return nil, x402.NewSettleError(ErrFeePayerMismatch, verifyResp.Payer, network, "", - fmt.Sprintf("expected %s, got %s", expectedFeePayer, actualFeePayer)) - } - // Sign with the feePayer's signer if err := f.signer.SignTransaction(ctx, tx, expectedFeePayer, string(requirements.Network)); err != nil { + f.settlementCache.Delete(txKey) return nil, x402.NewSettleError(ErrTransactionFailed, verifyResp.Payer, network, "", err.Error()) } @@ -339,63 +342,38 @@ func (f *ExactSvmSchemeV1) Settle( // verifyComputeLimitInstruction verifies the compute unit limit instruction func (f *ExactSvmSchemeV1) verifyComputeLimitInstruction(tx *solana.Transaction, inst solana.CompiledInstruction) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - if !progID.Equals(solana.ComputeBudget) { + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil || !progID.Equals(solana.ComputeBudget) { return errors.New(ErrComputeLimitInstruction) } // Check discriminator (should be 2 for SetComputeUnitLimit) - if len(inst.Data) < 1 || inst.Data[0] != 2 { + if len(inst.Data) < 5 || inst.Data[0] != 2 { return errors.New(ErrComputeLimitInstruction) } // Decode to validate format - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { - return errors.New(ErrComputeLimitInstruction) - } - - _, err = computebudget.DecodeInstruction(accounts, inst.Data) - if err != nil { - return errors.New(ErrComputeLimitInstruction) - } - return nil } // verifyComputePriceInstruction verifies the compute unit price instruction func (f *ExactSvmSchemeV1) verifyComputePriceInstruction(tx *solana.Transaction, inst solana.CompiledInstruction) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - if !progID.Equals(solana.ComputeBudget) { + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil || !progID.Equals(solana.ComputeBudget) { return errors.New(ErrComputePriceInstruction) } // Check discriminator (should be 3 for SetComputeUnitPrice) - if len(inst.Data) < 1 || inst.Data[0] != 3 { + if len(inst.Data) < 9 || inst.Data[0] != 3 { return errors.New(ErrComputePriceInstruction) } // Decode to get microLamports - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { - return errors.New(ErrComputePriceInstruction) - } - - decoded, err := computebudget.DecodeInstruction(accounts, inst.Data) - if err != nil { - return errors.New(ErrComputePriceInstruction) - } - + microLamports := binary.LittleEndian.Uint64(inst.Data[1:9]) // Check if it's SetComputeUnitPrice and validate the price - if priceInst, ok := decoded.Impl.(*computebudget.SetComputeUnitPrice); ok { + if microLamports > uint64(svm.MaxComputeUnitPriceMicrolamports) { // Check if price exceeds maximum (5 lamports per compute unit = 5,000,000 microlamports) - if priceInst.MicroLamports > uint64(svm.MaxComputeUnitPriceMicrolamports) { - return errors.New(ErrComputePriceInstructionTooHigh) - } - } else { - return errors.New(ErrComputePriceInstruction) + return errors.New(ErrComputePriceInstructionTooHigh) } return nil @@ -408,7 +386,10 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction( requirements types.PaymentRequirementsV1, signerAddresses []string, ) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] + progID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil { + return errors.New(ErrNoTransferInstruction) + } // Must be Token Program or Token-2022 Program if progID != solana.TokenProgramID && progID != solana.Token2022ProgramID { @@ -475,11 +456,11 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction( requiredAmount, err := strconv.ParseUint(amountStr, 10, 64) if err != nil { - return errors.New(ErrAmountInsufficient) + return errors.New(ErrAmountMismatch) } - if *transferChecked.Amount < requiredAmount { - return errors.New(ErrAmountInsufficient) + if *transferChecked.Amount != requiredAmount { + return errors.New(ErrAmountMismatch) } return nil diff --git a/go/mechanisms/svm/exact/v1/facilitator/scheme_test.go b/go/mechanisms/svm/exact/v1/facilitator/scheme_test.go new file mode 100644 index 0000000000..16a84089fb --- /dev/null +++ b/go/mechanisms/svm/exact/v1/facilitator/scheme_test.go @@ -0,0 +1,184 @@ +package facilitator + +import ( + "context" + "encoding/json" + "errors" + "testing" + + solana "github.com/gagliardetto/solana-go" + computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/programs/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + "github.com/x402-foundation/x402/go/v2/types" +) + +type mockV1Signer struct { + addresses []solana.PublicKey + signCalls int + simulateCalls int + sendCalls int +} + +func (m *mockV1Signer) GetAddresses(_ context.Context, _ string) []solana.PublicKey { + return m.addresses +} +func (m *mockV1Signer) SignTransaction(_ context.Context, _ *solana.Transaction, _ solana.PublicKey, _ string) error { + m.signCalls++ + return nil +} +func (m *mockV1Signer) SimulateTransaction(_ context.Context, _ *solana.Transaction, _ string) error { + m.simulateCalls++ + return nil +} +func (m *mockV1Signer) SendTransaction(_ context.Context, _ *solana.Transaction, _ string) (solana.Signature, error) { + m.sendCalls++ + return solana.SignatureFromBytes(append([]byte{1}, make([]byte, 63)...)), nil +} +func (m *mockV1Signer) ConfirmTransaction(_ context.Context, _ solana.Signature, _ string) error { + return nil +} + +func signV1Tx(t *testing.T, tx *solana.Transaction, key solana.PrivateKey) { + t.Helper() + messageBytes, err := tx.Message.MarshalBinary() + require.NoError(t, err) + signature, err := key.Sign(messageBytes) + require.NoError(t, err) + index, err := tx.GetAccountIndex(key.PublicKey()) + require.NoError(t, err) + n := int(tx.Message.Header.NumRequiredSignatures) + if len(tx.Signatures) < n { + sigs := make([]solana.Signature, n) + copy(sigs, tx.Signatures) + tx.Signatures = sigs + } + tx.Signatures[index] = signature +} + +func buildV1Fixture(t *testing.T) (types.PaymentPayloadV1, types.PaymentRequirementsV1, solana.PublicKey, *solana.Transaction, solana.PrivateKey) { + t.Helper() + facilitatorAddr := solana.NewWallet().PublicKey() + ownerWallet := solana.NewWallet() + owner := ownerWallet.PublicKey() + mint := solana.NewWallet().PublicKey() + payTo := solana.NewWallet().PublicKey() + + sourceATA, _, err := solana.FindAssociatedTokenAddress(owner, mint) + require.NoError(t, err) + destATA, _, err := solana.FindAssociatedTokenAddress(payTo, mint) + require.NoError(t, err) + + cuLimit, err := computebudget.NewSetComputeUnitLimitInstructionBuilder().SetUnits(200000).ValidateAndBuild() + require.NoError(t, err) + cuPrice, err := computebudget.NewSetComputeUnitPriceInstructionBuilder().SetMicroLamports(1000).ValidateAndBuild() + require.NoError(t, err) + transferIx, err := token.NewTransferCheckedInstructionBuilder(). + SetAmount(1000).SetDecimals(6). + SetSourceAccount(sourceATA).SetMintAccount(mint). + SetDestinationAccount(destATA).SetOwnerAccount(owner). + ValidateAndBuild() + require.NoError(t, err) + + blockhash, err := solana.HashFromBase58("5Tx8F3jgSHx21CbtjwmdaKPLM5tWmreWAnPrbqHomSJF") + require.NoError(t, err) + tx, err := solana.NewTransactionBuilder(). + AddInstruction(cuLimit).AddInstruction(cuPrice).AddInstruction(transferIx). + SetRecentBlockHash(blockhash).SetFeePayer(facilitatorAddr).Build() + require.NoError(t, err) + signV1Tx(t, tx, ownerWallet.PrivateKey) + + encoded, err := svm.EncodeTransaction(tx) + require.NoError(t, err) + extra, err := json.Marshal(map[string]string{"feePayer": facilitatorAddr.String()}) + require.NoError(t, err) + raw := json.RawMessage(extra) + requirements := types.PaymentRequirementsV1{ + Scheme: svm.SchemeExact, + Network: "solana-devnet", + Asset: mint.String(), + MaxAmountRequired: "1000", + PayTo: payTo.String(), + Extra: &raw, + } + payload := types.PaymentPayloadV1{ + X402Version: 1, + Scheme: svm.SchemeExact, + Network: "solana-devnet", + Payload: (&svm.ExactSvmPayload{Transaction: encoded}).ToMap(), + } + return payload, requirements, facilitatorAddr, tx, ownerWallet.PrivateKey +} + +func TestExactSvmSchemeV1_VerifyNeverSigns(t *testing.T) { + payload, requirements, facilitatorAddr, _, _ := buildV1Fixture(t) + signer := &mockV1Signer{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmSchemeV1(signer) + + resp, err := scheme.Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, 0, signer.signCalls) + assert.Equal(t, 1, signer.simulateCalls) +} + +func TestExactSvmSchemeV1_LookupTableRejected(t *testing.T) { + payload, requirements, facilitatorAddr, tx, ownerKey := buildV1Fixture(t) + table := solana.NewWallet().PublicKey() + tx.Message.SetVersion(solana.MessageVersionV0) + tx.Message.AddressTableLookups = solana.MessageAddressTableLookupSlice{{ + AccountKey: table, + }} + require.NoError(t, tx.Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{table: {}})) + signV1Tx(t, tx, ownerKey) + encoded, err := svm.EncodeTransaction(tx) + require.NoError(t, err) + payload.Payload = (&svm.ExactSvmPayload{Transaction: encoded}).ToMap() + + signer := &mockV1Signer{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmSchemeV1(signer) + _, verr := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, verr) + require.True(t, errors.As(verr, &ve)) + assert.Equal(t, ErrTransactionCouldNotBeDecoded, ve.InvalidReason) +} + +func TestExactSvmSchemeV1_VerifyRejectsOverpayment(t *testing.T) { + payload, requirements, facilitatorAddr, _, _ := buildV1Fixture(t) + requirements.MaxAmountRequired = "500" + signer := &mockV1Signer{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmSchemeV1(signer) + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + var ve *x402.VerifyError + require.Error(t, err) + require.True(t, errors.As(err, &ve)) + assert.Equal(t, ErrAmountMismatch, ve.InvalidReason) + assert.Equal(t, 0, signer.signCalls) +} + +func TestExactSvmSchemeV1_DuplicateSettleReturnsBeforeVerification(t *testing.T) { + payload, requirements, facilitatorAddr, _, _ := buildV1Fixture(t) + signer := &mockV1Signer{addresses: []solana.PublicKey{facilitatorAddr}} + scheme := NewExactSvmSchemeV1(signer) + + svmPayload, err := svm.PayloadFromMap(payload.Payload) + require.NoError(t, err) + tx, err := svm.DecodeTransaction(svmPayload.Transaction) + require.NoError(t, err) + txKey, err := svm.MessageHash(tx) + require.NoError(t, err) + assert.False(t, scheme.settlementCache.IsDuplicate(txKey)) + + _, err = scheme.Settle(context.Background(), payload, requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrDuplicateSettlement, se.ErrorReason) + assert.Equal(t, 0, signer.signCalls) + assert.Equal(t, 0, signer.simulateCalls) +} diff --git a/go/mechanisms/svm/paymentchannels/discovery.go b/go/mechanisms/svm/paymentchannels/discovery.go index 0392e1dc15..85dcdccd22 100644 --- a/go/mechanisms/svm/paymentchannels/discovery.go +++ b/go/mechanisms/svm/paymentchannels/discovery.go @@ -16,6 +16,12 @@ type DiscoveredChannel struct { Channel Channel } +// ProgramAccountsQuerier lists onchain program accounts. Facilitator signers +// implement this via a thin adapter at the call site. +type ProgramAccountsQuerier interface { + GetProgramAccounts(ctx context.Context, opts *rpc.GetProgramAccountsOpts) (rpc.GetProgramAccountsResult, error) +} + // DiscoverChannelsByRentPayer finds every payment-channels account this // facilitator key fronted rent for, per spec §6. It filters onchain by // rent_payer and account size, then rejects any match that fails full @@ -26,11 +32,11 @@ type DiscoveredChannel struct { // time ChannelStorage record, which also carries the distribution recipient. func DiscoverChannelsByRentPayer( ctx context.Context, - rpcClient *rpc.Client, + querier ProgramAccountsQuerier, rentPayer solana.PublicKey, ) ([]DiscoveredChannel, error) { accountSize := uint64(ChannelAccountSize) - results, err := rpcClient.GetProgramAccountsWithOpts(ctx, ProgramID, &rpc.GetProgramAccountsOpts{ + results, err := querier.GetProgramAccounts(ctx, &rpc.GetProgramAccountsOpts{ Encoding: solana.EncodingBase64, Commitment: rpc.CommitmentConfirmed, DataSlice: nil, diff --git a/go/mechanisms/svm/paymentchannels/discovery_test.go b/go/mechanisms/svm/paymentchannels/discovery_test.go index abd2db4ed2..c0e18723cf 100644 --- a/go/mechanisms/svm/paymentchannels/discovery_test.go +++ b/go/mechanisms/svm/paymentchannels/discovery_test.go @@ -1,6 +1,7 @@ package paymentchannels import ( + "context" "encoding/base64" "encoding/json" "net/http" @@ -94,6 +95,17 @@ func validDiscoveryChannel(t *testing.T, rentPayer solana.PublicKey) (solana.Pub return pda, data } +type rpcProgramAccountsQuerier struct { + client *rpc.Client +} + +func (q rpcProgramAccountsQuerier) GetProgramAccounts( + ctx context.Context, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + return q.client.GetProgramAccountsWithOpts(ctx, ProgramID, opts) +} + func TestDiscoverChannelsByRentPayer_AcceptsValidatedAccount(t *testing.T) { rentPayer := testKeypair(t).PublicKey() pda, data := validDiscoveryChannel(t, rentPayer) @@ -102,7 +114,7 @@ func TestDiscoverChannelsByRentPayer_AcceptsValidatedAccount(t *testing.T) { {pubkey: pda, owner: ProgramID, data: data}, }) - discovered, err := DiscoverChannelsByRentPayer(t.Context(), client, rentPayer) + discovered, err := DiscoverChannelsByRentPayer(t.Context(), rpcProgramAccountsQuerier{client: client}, rentPayer) require.NoError(t, err) require.Len(t, discovered, 1) assert.Equal(t, pda, discovered[0].ChannelID) @@ -123,7 +135,7 @@ func TestDiscoverChannelsByRentPayer_RejectsWrongOwner(t *testing.T) { {pubkey: pda, owner: testKeypair(t).PublicKey(), data: data}, }) - discovered, err := DiscoverChannelsByRentPayer(t.Context(), client, rentPayer) + discovered, err := DiscoverChannelsByRentPayer(t.Context(), rpcProgramAccountsQuerier{client: client}, rentPayer) require.NoError(t, err) assert.Empty(t, discovered) } @@ -139,7 +151,7 @@ func TestDiscoverChannelsByRentPayer_RejectsPDAMismatch(t *testing.T) { {pubkey: wrongPubkey, owner: ProgramID, data: data}, }) - discovered, err := DiscoverChannelsByRentPayer(t.Context(), client, rentPayer) + discovered, err := DiscoverChannelsByRentPayer(t.Context(), rpcProgramAccountsQuerier{client: client}, rentPayer) require.NoError(t, err) assert.Empty(t, discovered) } @@ -152,7 +164,7 @@ func TestDiscoverChannelsByRentPayer_RejectsMalformedAccount(t *testing.T) { {pubkey: pda, owner: ProgramID, data: []byte{0x01, 0x02}}, }) - discovered, err := DiscoverChannelsByRentPayer(t.Context(), client, rentPayer) + discovered, err := DiscoverChannelsByRentPayer(t.Context(), rpcProgramAccountsQuerier{client: client}, rentPayer) require.NoError(t, err) assert.Empty(t, discovered) } diff --git a/go/mechanisms/svm/pending_settlement.go b/go/mechanisms/svm/pending_settlement.go new file mode 100644 index 0000000000..b72509ca09 --- /dev/null +++ b/go/mechanisms/svm/pending_settlement.go @@ -0,0 +1,39 @@ +package svm + +import ( + "context" + "fmt" + + x402 "github.com/x402-foundation/x402/go/v2" +) + +// RecordPendingOrTerminal persists sigStr under key in store so a subsequent +// settle attempt for the same payload can reconcile against it instead of +// re-broadcasting, then returns the settlement_pending error carrying +// waitErr's message. +// +// If the store write itself fails, a later retry has no record to reconcile +// against — returning settlement_pending regardless would let it blindly +// re-verify/re-broadcast and risk a double-send. In that case this instead +// returns a terminal error (terminalReason), preserving sigStr for manual +// reconciliation. store may be nil, which disables the write and always +// returns settlement_pending (mirrors every other PendingSettlementStore +// call site, where a nil store means the bookkeeping is simply skipped). +func RecordPendingOrTerminal( + ctx context.Context, + store x402.PendingSettlementStore, + key string, + sigStr string, + payer string, + network x402.Network, + terminalReason string, + waitErr error, +) error { + if store != nil { + if setErr := store.Set(ctx, key, sigStr); setErr != nil { + return x402.NewSettleError(terminalReason, payer, network, sigStr, + fmt.Sprintf("settlement_pending, but failed to persist for retry: %s", setErr.Error())) + } + } + return x402.NewSettleError(x402.ErrSettlementPending, payer, network, sigStr, waitErr.Error()) +} diff --git a/go/mechanisms/svm/smart_wallet_rpc.go b/go/mechanisms/svm/smart_wallet_rpc.go new file mode 100644 index 0000000000..e04b66d212 --- /dev/null +++ b/go/mechanisms/svm/smart_wallet_rpc.go @@ -0,0 +1,228 @@ +package svm + +import ( + "context" + "encoding/base64" + "encoding/binary" + "fmt" + "strconv" + + solana "github.com/gagliardetto/solana-go" + addresslookuptable "github.com/gagliardetto/solana-go/programs/address-lookup-table" + "github.com/gagliardetto/solana-go/rpc" +) + +// maxSupportedTxVersion is passed to getTransaction so v0 (lookup-table) +// transactions are returned instead of rejected. +var maxSupportedTxVersion uint64 + +const ixTokenTransferChecked = 12 + +// simulateInnerIx accepts both compiled (programIdIndex/accounts/data) and +// jsonParsed (programId + parsed.type/info) inner instructions. Current Solana +// RPCs return jsonParsed TransferChecked CPIs from simulateTransaction. +type simulateInnerIx struct { + ProgramIDIndex uint16 `json:"programIdIndex"` + Accounts []uint16 `json:"accounts"` + Data solana.Base58 `json:"data"` + ProgramID string `json:"programId"` + Parsed *struct { + Type string `json:"type"` + Info map[string]interface{} `json:"info"` + } `json:"parsed"` +} + +type simulateInnerGroup struct { + Index uint16 `json:"index"` + Instructions []simulateInnerIx `json:"instructions"` +} + +type simulateWithInnerResult struct { + Value *struct { + Err interface{} `json:"err"` + InnerInstructions []simulateInnerGroup `json:"innerInstructions"` + } `json:"value"` +} + +// SimulateWithInnerInstructions simulates a transaction with inner instruction +// recording. Signature verification is off; the fee-payer slot may be empty. +func SimulateWithInnerInstructions(ctx context.Context, client *rpc.Client, tx *solana.Transaction) ([]rpc.InnerInstruction, error) { + txData, err := tx.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("failed to encode transaction: %w", err) + } + params := []interface{}{ + base64.StdEncoding.EncodeToString(txData), + rpc.M{ + "encoding": "base64", + "sigVerify": false, + "commitment": DefaultCommitment, + "innerInstructions": true, + }, + } + var out simulateWithInnerResult + if err := client.RPCCallForInto(ctx, &out, "simulateTransaction", params); err != nil { + return nil, fmt.Errorf("simulation failed: %w", err) + } + if out.Value == nil { + return nil, fmt.Errorf("simulation failed: empty result") + } + if out.Value.Err != nil { + return nil, fmt.Errorf("simulation failed: %v", out.Value.Err) + } + return normalizeSimulatedInnerInstructions(out.Value.InnerInstructions, tx.Message.AccountKeys), nil +} + +func accountIndex(keys solana.PublicKeySlice, address string) (uint16, bool) { + pk, err := solana.PublicKeyFromBase58(address) + if err != nil { + return 0, false + } + for i, key := range keys { + if key.Equals(pk) { + return uint16(i), true + } + } + return 0, false +} + +func parsedTransferAmount(info map[string]interface{}) (uint64, bool) { + if tokenAmount, ok := info["tokenAmount"].(map[string]interface{}); ok { + if amount, ok := tokenAmount["amount"].(string); ok { + parsed, err := strconv.ParseUint(amount, 10, 64) + return parsed, err == nil + } + } + switch amount := info["amount"].(type) { + case string: + parsed, err := strconv.ParseUint(amount, 10, 64) + return parsed, err == nil + case float64: + return uint64(amount), true + } + return 0, false +} + +func compiledFromSimulatedInner(ix simulateInnerIx, keys solana.PublicKeySlice) (rpc.CompiledInstruction, bool) { + if len(ix.Data) > 0 { + return rpc.CompiledInstruction{ + ProgramIDIndex: ix.ProgramIDIndex, + Accounts: ix.Accounts, + Data: ix.Data, + }, true + } + if ix.Parsed == nil || ix.Parsed.Type != "transferChecked" || ix.Parsed.Info == nil { + return rpc.CompiledInstruction{}, false + } + + info := ix.Parsed.Info + mint, _ := info["mint"].(string) + destination, _ := info["destination"].(string) + authority, _ := info["authority"].(string) + source, _ := info["source"].(string) + amount, ok := parsedTransferAmount(info) + if !ok || mint == "" || destination == "" || authority == "" { + return rpc.CompiledInstruction{}, false + } + + programID := ix.ProgramID + if programID == "" { + return rpc.CompiledInstruction{}, false + } + programIdx, ok := accountIndex(keys, programID) + if !ok { + return rpc.CompiledInstruction{}, false + } + sourceIdx, okSource := accountIndex(keys, source) + mintIdx, okMint := accountIndex(keys, mint) + destIdx, okDest := accountIndex(keys, destination) + authIdx, okAuth := accountIndex(keys, authority) + if !okMint || !okDest || !okAuth { + return rpc.CompiledInstruction{}, false + } + if !okSource { + sourceIdx = 0 + } + + data := make([]byte, 9) + data[0] = ixTokenTransferChecked + binary.LittleEndian.PutUint64(data[1:], amount) + return rpc.CompiledInstruction{ + ProgramIDIndex: programIdx, + Accounts: []uint16{sourceIdx, mintIdx, destIdx, authIdx}, + Data: solana.Base58(data), + }, true +} + +func normalizeSimulatedInnerInstructions(groups []simulateInnerGroup, keys solana.PublicKeySlice) []rpc.InnerInstruction { + if groups == nil { + return nil + } + out := make([]rpc.InnerInstruction, 0, len(groups)) + for _, group := range groups { + converted := rpc.InnerInstruction{Index: group.Index} + for _, ix := range group.Instructions { + compiled, ok := compiledFromSimulatedInner(ix, keys) + if !ok { + continue + } + converted.Instructions = append(converted.Instructions, compiled) + } + out = append(out, converted) + } + return out +} + +// ConfirmedTransactionInnerInstructions fetches a confirmed transaction's CPI +// trace and the loaded account-key list inner-instruction indices address. +func ConfirmedTransactionInnerInstructions(ctx context.Context, client *rpc.Client, signature solana.Signature) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + result, err := client.GetTransaction(ctx, signature, &rpc.GetTransactionOpts{ + Encoding: solana.EncodingBase64, + Commitment: DefaultCommitment, + MaxSupportedTransactionVersion: &maxSupportedTxVersion, + }) + if err != nil { + return nil, nil, err + } + if result == nil || result.Meta == nil || result.Transaction == nil { + return nil, nil, fmt.Errorf("transaction not indexed") + } + tx, err := result.Transaction.GetTransaction() + if err != nil { + return nil, nil, fmt.Errorf("failed to decode confirmed transaction: %w", err) + } + keys := append(solana.PublicKeySlice{}, tx.Message.AccountKeys...) + keys = append(keys, result.Meta.LoadedAddresses.Writable...) + keys = append(keys, result.Meta.LoadedAddresses.ReadOnly...) + return result.Meta.InnerInstructions, keys, nil +} + +// TokenAccountBalance returns the raw token amount of an ATA. The bool is +// false when the account does not exist. +func TokenAccountBalance(ctx context.Context, client *rpc.Client, tokenAccount solana.PublicKey) (uint64, bool, error) { + result, err := client.GetTokenAccountBalance(ctx, tokenAccount, DefaultCommitment) + if err != nil { + return 0, false, err + } + if result == nil || result.Value == nil { + return 0, false, nil + } + amount, err := strconv.ParseUint(result.Value.Amount, 10, 64) + if err != nil { + return 0, false, fmt.Errorf("invalid token account balance: %w", err) + } + return amount, true, nil +} + +// AddressLookupTables fetches the address lists stored in the given lookup tables. +func AddressLookupTables(ctx context.Context, client *rpc.Client, tables []solana.PublicKey) (map[solana.PublicKey]solana.PublicKeySlice, error) { + out := make(map[solana.PublicKey]solana.PublicKeySlice, len(tables)) + for _, table := range tables { + state, err := addresslookuptable.GetAddressLookupTable(ctx, client, table) + if err != nil { + return nil, fmt.Errorf("failed to fetch address lookup table %s: %w", table, err) + } + out[table] = state.Addresses + } + return out, nil +} diff --git a/go/mechanisms/svm/smart_wallet_rpc_test.go b/go/mechanisms/svm/smart_wallet_rpc_test.go new file mode 100644 index 0000000000..eee7a0dc64 --- /dev/null +++ b/go/mechanisms/svm/smart_wallet_rpc_test.go @@ -0,0 +1,76 @@ +package svm + +import ( + "encoding/binary" + "encoding/json" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeSimulatedInnerInstructions_ParsedTransferChecked(t *testing.T) { + program := solana.TokenProgramID + source := solana.NewWallet().PublicKey() + mint := solana.NewWallet().PublicKey() + dest := solana.NewWallet().PublicKey() + authority := solana.NewWallet().PublicKey() + keys := solana.PublicKeySlice{source, mint, dest, authority, program} + + raw := []byte(`[{ + "index": 2, + "instructions": [{ + "parsed": { + "info": { + "authority": "` + authority.String() + `", + "destination": "` + dest.String() + `", + "mint": "` + mint.String() + `", + "source": "` + source.String() + `", + "tokenAmount": {"amount": "1000", "decimals": 6} + }, + "type": "transferChecked" + }, + "program": "spl-token", + "programId": "` + program.String() + `", + "stackHeight": 2 + }] + }]`) + + var groups []simulateInnerGroup + require.NoError(t, json.Unmarshal(raw, &groups)) + + normalized := normalizeSimulatedInnerInstructions(groups, keys) + require.Len(t, normalized, 1) + require.Len(t, normalized[0].Instructions, 1) + + ix := normalized[0].Instructions[0] + assert.Equal(t, uint16(4), ix.ProgramIDIndex) + require.Len(t, ix.Accounts, 4) + assert.Equal(t, []uint16{0, 1, 2, 3}, ix.Accounts) + require.GreaterOrEqual(t, len(ix.Data), 9) + assert.Equal(t, byte(ixTokenTransferChecked), ix.Data[0]) + assert.Equal(t, uint64(1000), binary.LittleEndian.Uint64(ix.Data[1:9])) +} + +func TestNormalizeSimulatedInnerInstructions_CompiledPassthrough(t *testing.T) { + data := make([]byte, 9) + data[0] = ixTokenTransferChecked + binary.LittleEndian.PutUint64(data[1:], 500) + + groups := []simulateInnerGroup{{ + Index: 1, + Instructions: []simulateInnerIx{{ + ProgramIDIndex: 5, + Accounts: []uint16{1, 2, 3, 4}, + Data: solana.Base58(data), + }}, + }} + + normalized := normalizeSimulatedInnerInstructions(groups, nil) + require.Len(t, normalized, 1) + require.Len(t, normalized[0].Instructions, 1) + assert.Equal(t, uint16(5), normalized[0].Instructions[0].ProgramIDIndex) + assert.Equal(t, []uint16{1, 2, 3, 4}, normalized[0].Instructions[0].Accounts) + assert.Equal(t, solana.Base58(data), normalized[0].Instructions[0].Data) +} diff --git a/go/mechanisms/svm/types.go b/go/mechanisms/svm/types.go index 7adcda95b0..0fdd24097d 100644 --- a/go/mechanisms/svm/types.go +++ b/go/mechanisms/svm/types.go @@ -6,6 +6,7 @@ import ( "fmt" solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" ) // ExactSvmPayload represents a SVM (Solana) payment payload @@ -19,6 +20,9 @@ type ExactSvmPayloadV1 = ExactSvmPayload // ExactSvmPayloadV2 - alias for v2 (currently identical, reserved for future) type ExactSvmPayloadV2 = ExactSvmPayload +// UptoSvmPayloadV2 is an alias for v2 compatibility. +type UptoSvmPayloadV2 = UptoSvmPayload + // UptoSvmPayload is the SVM `upto` payment payload: the client-signed channel // `open` plus the channel facts the facilitator rebinds it against. type UptoSvmPayload struct { @@ -116,9 +120,34 @@ func IsUptoSvmPayload(payload map[string]interface{}) bool { return false } } + if !jsonNumberIsInt64(payload["expiresAt"]) { + return false + } + if !jsonNumberIsInt64(payload["validAfter"]) { + return false + } + if voucher, present := payload[UptoVoucherSignatureField]; present { + if _, ok := voucher.(string); !ok { + return false + } + } return true } +func jsonNumberIsInt64(value interface{}) bool { + switch v := value.(type) { + case float64: + return v == float64(int64(v)) + case int64, int: + return true + case json.Number: + _, err := v.Int64() + return err == nil + default: + return false + } +} + // HasUptoVoucherSignature reports whether the payload carries the voucher key // at all. Presence — not emptiness — distinguishes a claim settle from a // deposit settle, so an empty client-supplied value is still a rejection. @@ -149,8 +178,9 @@ type FacilitatorSvmSigner interface { // Returns error if no signer exists for feePayer or signing fails SignTransaction(ctx context.Context, tx *solana.Transaction, feePayer solana.PublicKey, network string) error - // SimulateTransaction simulates a signed transaction to verify it would succeed - // Returns error if simulation fails + // SimulateTransaction simulates a transaction to verify it would succeed. + // Does not verify signatures (RPC sigVerify is off). Callers must verify + // required signatures themselves; the fee-payer slot may be empty. SimulateTransaction(ctx context.Context, tx *solana.Transaction, network string) error // SendTransaction sends a signed transaction to the network @@ -162,6 +192,39 @@ type FacilitatorSvmSigner interface { ConfirmTransaction(ctx context.Context, signature solana.Signature, network string) error } +// FacilitatorSimulateTransactionOptions configures facilitator transaction +// simulation. Nil pointer fields use RPC defaults (sigVerify off, +// replaceRecentBlockhash off). +type FacilitatorSimulateTransactionOptions struct { + SigVerify *bool + ReplaceRecentBlockhash *bool + Commitment rpc.CommitmentType +} + +// FacilitatorAccountInfo is the account shape returned by GetAccountInfo on +// facilitator signers that expose read RPC. +type FacilitatorAccountInfo struct { + Data solana.Data + Owner solana.PublicKey + Lamports uint64 +} + +// FacilitatorProgramAccount is one row from GetProgramAccounts. +type FacilitatorProgramAccount struct { + Pubkey solana.PublicKey + Account FacilitatorAccountInfo +} + +// SmartWalletRPCCapabilities is the extra read-only RPC surface a +// FacilitatorSvmSigner must also provide for a facilitator to verify payments +// made by a smart wallet. +type SmartWalletRPCCapabilities interface { + SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, network string) ([]rpc.InnerInstruction, error) + GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, network string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) + GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, network string) (uint64, bool, error) + FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, network string) (map[solana.PublicKey]solana.PublicKeySlice, error) +} + // ReceiverAuthorizerSigner is the server-controlled hot key advertised as // `extra.receiverAuthorizer`. It signs settlement vouchers as raw Ed25519 // messages and never signs a transaction, so it needs no SOL or token balance. diff --git a/go/mechanisms/svm/types_test.go b/go/mechanisms/svm/types_test.go new file mode 100644 index 0000000000..3265a1ae27 --- /dev/null +++ b/go/mechanisms/svm/types_test.go @@ -0,0 +1,77 @@ +package svm + +import ( + "testing" +) + +func TestExactSvmPayloadV1Structure(t *testing.T) { + payload := ExactSvmPayloadV1{Transaction: "base64encodedtransaction=="} + if payload.Transaction == "" { + t.Fatal("expected transaction field") + } +} + +func TestExactSvmPayloadV2CompatibleWithV1(t *testing.T) { + payload := ExactSvmPayloadV2{Transaction: "base64encodedtransaction=="} + v1 := ExactSvmPayloadV1(payload) + if v1.Transaction != payload.Transaction { + t.Fatalf("expected V2 to be assignable to V1") + } +} + +func TestUptoSvmPayloadV2Alias(t *testing.T) { + payload := UptoSvmPayloadV2{ + From: "From1111111111111111111111111111111111111", + MaxAmount: "1000", + ExpiresAt: 1_700_000_000, + ValidAfter: 1_699_999_000, + Nonce: "1", + OpenSlot: "341000000", + ChannelId: "Channel111111111111111111111111111111111111", + Deposit: "1000", + AuthorizedSigner: "Auth1111111111111111111111111111111111111", + OpenTransaction: "dGVzdA==", + } + if payload.From == "" { + t.Fatal("expected from field on UptoSvmPayloadV2 alias") + } +} + +func TestIsUptoSvmPayload(t *testing.T) { + valid := map[string]interface{}{ + "from": "From1111111111111111111111111111111111111", + "maxAmount": "1000", + "deposit": "1000", + "channelId": "Channel111111111111111111111111111111111111", + "authorizedSigner": "Auth1111111111111111111111111111111111111", + "openTransaction": "dGVzdA==", + "openSlot": "341000000", + "nonce": "1", + "expiresAt": float64(1_700_000_000), + "validAfter": float64(1_699_999_000), + } + if !IsUptoSvmPayload(valid) { + t.Fatal("expected valid upto payload") + } + + missingExpires := map[string]interface{}{} + for k, v := range valid { + missingExpires[k] = v + } + delete(missingExpires, "expiresAt") + if IsUptoSvmPayload(missingExpires) { + t.Fatal("expected missing expiresAt to fail the guard") + } +} + +func TestPayloadFromMapRequiresTransaction(t *testing.T) { + if _, err := PayloadFromMap(map[string]interface{}{}); err == nil { + t.Fatal("expected error for missing transaction") + } +} + +func TestUptoPayloadFromMapRequiresFields(t *testing.T) { + if _, err := UptoPayloadFromMap(map[string]interface{}{"from": "x"}); err == nil { + t.Fatal("expected error for incomplete upto payload") + } +} diff --git a/go/mechanisms/svm/upto/facilitator/channel.go b/go/mechanisms/svm/upto/facilitator/channel.go index f2c852e915..8e333968f2 100644 --- a/go/mechanisms/svm/upto/facilitator/channel.go +++ b/go/mechanisms/svm/upto/facilitator/channel.go @@ -15,6 +15,10 @@ import ( "github.com/x402-foundation/x402/go/v2/mechanisms/svm/upto" ) +// simPlaceholderBlockhash is compiled into deposit composite sims; the RPC +// replaces it before simulation. +var simPlaceholderBlockhash = solana.Hash{} + // simComputeUnitLimit is the Solana per-transaction compute maximum. Only used // for facilitator-built simulations: the composite open + settle + distribute // exceeds the 400,000 CU ceiling the client open is capped at. @@ -109,10 +113,11 @@ func (c *verifiedOpenChannel) settlement(tokenProgram solana.PublicKey, network // value reports whether the account exists at all. func fetchChannelAccount( ctx context.Context, - rpcClient *rpc.Client, + signer UptoFacilitatorSigner, + network string, channelID solana.PublicKey, ) (*paymentchannels.Channel, bool, error) { - account, err := getChannelAccount(ctx, rpcClient, channelID) + account, err := getChannelAccount(ctx, signer, network, channelID) if err != nil { if errors.Is(err, rpc.ErrNotFound) { return nil, false, nil @@ -135,18 +140,24 @@ func fetchChannelAccount( // is visible to this one. func getChannelAccount( ctx context.Context, - rpcClient *rpc.Client, + signer UptoFacilitatorSigner, + network string, channelID solana.PublicKey, ) (*rpc.GetAccountInfoResult, error) { - return rpcClient.GetAccountInfoWithOpts(ctx, channelID, &rpc.GetAccountInfoOpts{ + return signer.GetAccountInfo(ctx, channelID, network, &rpc.GetAccountInfoOpts{ Encoding: solana.EncodingBase64, Commitment: upto.StateCommitment, }) } // channelExists reports whether the channel PDA is already allocated onchain. -func channelExists(ctx context.Context, rpcClient *rpc.Client, channelID solana.PublicKey) (bool, error) { - account, err := getChannelAccount(ctx, rpcClient, channelID) +func channelExists( + ctx context.Context, + signer UptoFacilitatorSigner, + network string, + channelID solana.PublicKey, +) (bool, error) { + account, err := getChannelAccount(ctx, signer, network, channelID) if err != nil { if errors.Is(err, rpc.ErrNotFound) { return false, nil @@ -160,12 +171,13 @@ func channelExists(ctx context.Context, rpcClient *rpc.Client, channelID solana. // the challenge terms before the facilitator settles against it. func fetchAndVerifyOpenChannel( ctx context.Context, - rpcClient *rpc.Client, + signer UptoFacilitatorSigner, + network string, channelID solana.PublicKey, expected expectedOpenChannel, ) (*verifiedOpenChannel, error) { for attempt := 1; attempt <= channelReadMaxAttempts; attempt++ { - channel, exists, err := fetchChannelAccount(ctx, rpcClient, channelID) + channel, exists, err := fetchChannelAccount(ctx, signer, network, channelID) if err != nil { return nil, err } @@ -250,6 +262,12 @@ func verifyOpenChannelAccount( // broadcastOpen co-signs the fee-payer slot of the client's partially signed // open, broadcasts it, and waits for confirmation. +// +// On a ConfirmTransaction failure the broadcast signature is still returned +// alongside the error (unlike a SignTransaction/SendTransaction failure, +// which returns "") so the caller can distinguish "never landed, safe to +// retry with a fresh broadcast" from "broadcast successfully but unconfirmed, +// must reconcile against this signature instead of re-broadcasting." func broadcastOpen( ctx context.Context, signer svm.FacilitatorSvmSigner, @@ -269,7 +287,7 @@ func broadcastOpen( return "", err } if err := signer.ConfirmTransaction(ctx, signature, network); err != nil { - return "", err + return signature.String(), err } return signature.String(), nil } @@ -299,8 +317,7 @@ type settlementChannel struct { // with signature verification off). func simulateOpenSettleDistribute( ctx context.Context, - rpcClient *rpc.Client, - signer svm.FacilitatorSvmSigner, + signer UptoFacilitatorSigner, feePayer solana.PublicKey, openTransactionBase64 string, channel settlementChannel, @@ -341,7 +358,7 @@ func simulateOpenSettleDistribute( } instructions = append(instructions, settleInstructions...) - return simulateInstructions(ctx, rpcClient, signer, feePayer, channel.Network, instructions) + return simulateInstructions(ctx, signer, feePayer, channel.Network, instructions) } // voucherArgs carry a signed voucher into settle_and_seal via the Ed25519 @@ -401,22 +418,17 @@ func buildSettleAndDistribute( } // buildSignedTransaction compiles instructions into a fee-payer-signed -// transaction anchored to a fresh blockhash. +// transaction anchored to blockhash. func buildSignedTransaction( ctx context.Context, - rpcClient *rpc.Client, signer svm.FacilitatorSvmSigner, feePayer solana.PublicKey, network string, + blockhash solana.Hash, instructions []solana.Instruction, ) (*solana.Transaction, error) { - latest, err := rpcClient.GetLatestBlockhash(ctx, upto.BlockhashCommitment) - if err != nil { - return nil, fmt.Errorf("failed to fetch latest blockhash: %w", err) - } - builder := solana.NewTransactionBuilder(). - SetRecentBlockHash(latest.Value.Blockhash). + SetRecentBlockHash(blockhash). SetFeePayer(feePayer) for _, instruction := range instructions { builder = builder.AddInstruction(instruction) @@ -442,29 +454,38 @@ func buildSignedTransaction( // are never landed and the composite open simulation carries an unsigned payer. func simulateInstructions( ctx context.Context, - rpcClient *rpc.Client, - signer svm.FacilitatorSvmSigner, + signer UptoFacilitatorSigner, feePayer solana.PublicKey, network string, instructions []solana.Instruction, ) error { - tx, err := buildSignedTransaction(ctx, rpcClient, signer, feePayer, network, instructions) + tx, err := buildSignedTransaction(ctx, signer, feePayer, network, simPlaceholderBlockhash, instructions) if err != nil { return err } - result, err := rpcClient.SimulateTransactionWithOpts(ctx, tx, &rpc.SimulateTransactionOpts{ + return signer.SimulateTransactionWithOpts(ctx, tx, network, &rpc.SimulateTransactionOpts{ SigVerify: false, ReplaceRecentBlockhash: true, Commitment: upto.StateCommitment, }) - if err != nil { - return fmt.Errorf("settlement simulation failed: %w", err) - } - if result != nil && result.Value != nil && result.Value.Err != nil { - return fmt.Errorf("settlement simulation failed: %v", result.Value.Err) +} + +// SettlementSimulationError is returned when explicit settlement simulation +// fails. The transaction is never broadcast. +type SettlementSimulationError struct { + Err error +} + +func (e *SettlementSimulationError) Error() string { + if e.Err != nil { + return e.Err.Error() } - return nil + return "settlement simulation failed" +} + +func (e *SettlementSimulationError) Unwrap() error { + return e.Err } // submitSettleOptions configures the ComputeBudget prefix submitSettle @@ -478,6 +499,9 @@ type submitSettleOptions struct { // ComputeUnitPriceMicroLamports overrides SetComputeUnitPrice; 0 omits // the instruction. Defaults to svm.DefaultComputeUnitPriceMicrolamports. ComputeUnitPriceMicroLamports *uint64 + // LatestBlockhash, when set, skips a blockhash fetch (e.g. prefetched in + // parallel with a channel read during claim settle). + LatestBlockhash *solana.Hash } // buildSettleComputeBudget builds the ComputeBudget prefix submitSettle @@ -517,8 +541,7 @@ func buildSettleComputeBudget(opts submitSettleOptions) ([]solana.Instruction, e // ComputeBudget SetComputeUnitLimit and optional SetComputeUnitPrice. func submitSettle( ctx context.Context, - rpcClient *rpc.Client, - signer svm.FacilitatorSvmSigner, + signer UptoFacilitatorSigner, feePayer solana.PublicKey, network string, instructions []solana.Instruction, @@ -529,13 +552,28 @@ func submitSettle( return "", err } + var blockhash solana.Hash + if opts.LatestBlockhash != nil { + blockhash = *opts.LatestBlockhash + } else { + latest, _, err := signer.GetLatestBlockhash(ctx, network) + if err != nil { + return "", fmt.Errorf("failed to fetch latest blockhash: %w", err) + } + blockhash = latest + } + tx, err := buildSignedTransaction( - ctx, rpcClient, signer, feePayer, network, append(computeBudget, instructions...), + ctx, signer, feePayer, network, blockhash, append(computeBudget, instructions...), ) if err != nil { return "", err } + if err := signer.SimulateTransaction(ctx, tx, network); err != nil { + return "", &SettlementSimulationError{Err: err} + } + signature, err := signer.SendTransaction(ctx, tx, network) if err != nil { return "", err diff --git a/go/mechanisms/svm/upto/facilitator/channel_test.go b/go/mechanisms/svm/upto/facilitator/channel_test.go index 89a5888236..efabe431af 100644 --- a/go/mechanisms/svm/upto/facilitator/channel_test.go +++ b/go/mechanisms/svm/upto/facilitator/channel_test.go @@ -32,12 +32,14 @@ func TestFetchAndVerifyOpenChannelRetriesMissingAccount(t *testing.T) { signer := newMockSigner(t, 1) fixture := newPaymentFixture(t, signer) rpcStub := newStubRPC(t) + signer.attachRPC(rpc.New(rpcStub.url)) rpcStub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) rpcStub.hideAccountForReads(fixture.channelID.String(), 1) verified, err := fetchAndVerifyOpenChannel( context.Background(), - rpc.New(rpcStub.url), + signer, + testNetwork, fixture.channelID, expectedFrom(fixture), ) @@ -50,12 +52,14 @@ func TestFetchAndVerifyOpenChannelStopsRetryingWhenContextEnds(t *testing.T) { signer := newMockSigner(t, 1) fixture := newPaymentFixture(t, signer) rpcStub := newStubRPC(t) + signer.attachRPC(rpc.New(rpcStub.url)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() _, err := fetchAndVerifyOpenChannel( ctx, - rpc.New(rpcStub.url), + signer, + testNetwork, fixture.channelID, expectedFrom(fixture), ) diff --git a/go/mechanisms/svm/upto/facilitator/errors.go b/go/mechanisms/svm/upto/facilitator/errors.go index 6164757fb6..fee6e2a9cf 100644 --- a/go/mechanisms/svm/upto/facilitator/errors.go +++ b/go/mechanisms/svm/upto/facilitator/errors.go @@ -1,5 +1,7 @@ package facilitator +import x402 "github.com/x402-foundation/x402/go/v2" + // Facilitator error codes for the SVM `upto` payment-channel scheme. const ( // Shared protocol-level codes. @@ -66,3 +68,13 @@ const ( // ErrPaymentRequirements is returned when the requirements are unusable. ErrPaymentRequirements = "invalid_upto_svm_payment_requirements" ) + +// ErrSettlementPending is the non-terminal settle error reason used when a +// deposit (open) or claim (settle_and_seal + distribute) transaction was +// broadcast but ConfirmTransaction couldn't observe its confirmation in time. +// It always carries the broadcast signature (as SettleError.Transaction) so a +// caller can reconcile onchain, and mirrors x402.ErrSettlementPending / +// evm.ErrSettlementPending so x402ResourceServer's generic +// single-retry-on-settlement_pending logic (see settleWithPendingRetry in +// server.go) recognizes it uniformly across schemes/networks. +const ErrSettlementPending = x402.ErrSettlementPending diff --git a/go/mechanisms/svm/upto/facilitator/pending_settlement_test.go b/go/mechanisms/svm/upto/facilitator/pending_settlement_test.go new file mode 100644 index 0000000000..54aab74390 --- /dev/null +++ b/go/mechanisms/svm/upto/facilitator/pending_settlement_test.go @@ -0,0 +1,276 @@ +package facilitator + +import ( + "context" + "errors" + "fmt" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" +) + +// Exercises the PendingSettlementStore fast path wired into UptoSvmScheme's +// settleDeposit and settleClaim (see scheme.go): a settle attempt whose +// ConfirmTransaction wait fails must populate the store keyed by the +// deposit/settlement cache key; a subsequent settle for the identical +// channel must hit that entry, skip verify/broadcast entirely (via +// awaitPendingUptoSignature), and reconcile against the already-broadcast +// signature. Mirrors the TS/Python SVM upto pending-settlement test suites. +// +// depositCacheKey/settlementCacheKey mirror the key formats inlined in +// settleDeposit/settleClaim (scheme.go); duplicated here (rather than +// exported from production code purely for tests) since the format is an +// internal implementation detail, not part of the store's contract. + +func depositCacheKey(network, channelID string) string { + return fmt.Sprintf("upto:deposit:%s:%s", network, channelID) +} + +func settlementCacheKey(network, channelID string) string { + return fmt.Sprintf("upto:%s:%s", network, channelID) +} + +func TestDepositSettle_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + signer.onSend = func(*solana.Transaction) { + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + } + + response, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) + require.NoError(t, err) + assert.True(t, response.Success) + + key := depositCacheKey(fixture.requirements.Network, fixture.channelID.String()) + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "a successful broadcast+confirm must never leave a pending entry") +} + +func TestDepositSettle_PendingSettlementStore_TerminalFailureNeverTouchesStore(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + // An existing channel account makes the deposit a terminal + // ErrChannelAlreadyOpen failure, never reaching broadcast. + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + + _, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) + assert.Equal(t, ErrChannelAlreadyOpen, settleErrorReason(t, err)) + assert.Empty(t, signer.sentTransactions()) + + key := depositCacheKey(fixture.requirements.Network, fixture.channelID.String()) + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "a terminal failure must never populate the pending-settlement store") +} + +func TestDepositSettle_PendingSettlementStore_CacheMissConfirmFailurePopulatesStore(t *testing.T) { + signer := newMockSigner(t, 1) + signer.confirmErr = errors.New("rpc: confirmation timeout") + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + + _, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + require.NotEmpty(t, se.Transaction, "the broadcast signature must be preserved on settlement_pending") + + key := depositCacheKey(fixture.requirements.Network, fixture.channelID.String()) + stored, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + require.True(t, ok, "confirm-wait failure must populate the pending-settlement store") + assert.Equal(t, se.Transaction, stored) +} + +func TestDepositSettle_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + key := depositCacheKey(fixture.requirements.Network, fixture.channelID.String()) + priorSig := "3vZ9Q4mF1x1sVEnTkGqwKN1JcgmXCzXfDMz6WrPPBhKtNBbNaRiJ8CT9jkuoAJyGVUvHNNbCTUmzL6yEsSm4x9wp" + require.NoError(t, scheme.pendingStore.Set(context.Background(), key, priorSig)) + + response, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) + require.NoError(t, err) + assert.True(t, response.Success) + assert.Equal(t, priorSig, response.Transaction) + assert.Equal(t, "10000", response.Amount) + assert.Empty(t, signer.sentTransactions(), "reconciliation must never re-broadcast the open") + assert.Zero(t, stub.simulations(), "reconciliation skips re-simulation entirely") + + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "successful reconciliation must clear the pending entry") +} + +func TestDepositSettle_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + signer := newMockSigner(t, 1) + signer.confirmErr = errors.New("still pending") + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + key := depositCacheKey(fixture.requirements.Network, fixture.channelID.String()) + priorSig := "3vZ9Q4mF1x1sVEnTkGqwKN1JcgmXCzXfDMz6WrPPBhKtNBbNaRiJ8CT9jkuoAJyGVUvHNNbCTUmzL6yEsSm4x9wp" + require.NoError(t, scheme.pendingStore.Set(context.Background(), key, priorSig)) + + _, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + assert.Equal(t, priorSig, se.Transaction) + assert.Empty(t, signer.sentTransactions(), "reconciliation must never re-broadcast the open") + + stored, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + require.True(t, ok) + assert.Equal(t, priorSig, stored) +} + +func TestClaimSettle_PendingSettlementStore_CacheMissSuccessLeavesNoEntry(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + + response, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 1858), fixture.claimRequirements(1858), nil, + ) + require.NoError(t, err) + assert.True(t, response.Success) + + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "a successful broadcast+confirm must never leave a pending entry") +} + +func TestClaimSettle_PendingSettlementStore_TerminalFailureNeverTouchesStore(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + + // 10001 exceeds the fixture's signed ceiling (deposit of 10000), so this + // fails ErrSettlementExceedsAmount before ever dispatching to settleClaim. + _, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 10001), fixture.claimRequirements(10001), nil, + ) + assert.Equal(t, ErrSettlementExceedsAmount, settleErrorReason(t, err)) + assert.Empty(t, signer.sentTransactions()) + + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "a terminal failure must never populate the pending-settlement store") +} + +func TestClaimSettle_PendingSettlementStore_CacheMissConfirmFailurePopulatesStore(t *testing.T) { + signer := newMockSigner(t, 1) + signer.confirmErr = errors.New("rpc: confirmation timeout") + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + + _, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 1858), fixture.claimRequirements(1858), nil, + ) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + require.NotEmpty(t, se.Transaction) + + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + stored, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + require.True(t, ok, "confirm-wait failure must populate the pending-settlement store") + assert.Equal(t, se.Transaction, stored) +} + +func TestClaimSettle_PendingSettlementStore_CacheHitReconcilesWithoutRebroadcast(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + priorSig := "3vZ9Q4mF1x1sVEnTkGqwKN1JcgmXCzXfDMz6WrPPBhKtNBbNaRiJ8CT9jkuoAJyGVUvHNNbCTUmzL6yEsSm4x9wp" + require.NoError(t, scheme.pendingStore.Set(context.Background(), key, priorSig)) + + response, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 1858), fixture.claimRequirements(1858), nil, + ) + require.NoError(t, err) + assert.True(t, response.Success) + assert.Equal(t, priorSig, response.Transaction) + assert.Equal(t, "1858", response.Amount) + assert.Empty(t, signer.sentTransactions(), "reconciliation must never re-submit settle_and_seal + distribute") + + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "successful reconciliation must clear the pending entry") +} + +func TestClaimSettle_PendingSettlementStore_CacheHitStillPendingReturnsAgainWithoutRebroadcast(t *testing.T) { + signer := newMockSigner(t, 1) + signer.confirmErr = errors.New("still pending") + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + priorSig := "3vZ9Q4mF1x1sVEnTkGqwKN1JcgmXCzXfDMz6WrPPBhKtNBbNaRiJ8CT9jkuoAJyGVUvHNNbCTUmzL6yEsSm4x9wp" + require.NoError(t, scheme.pendingStore.Set(context.Background(), key, priorSig)) + + _, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 1858), fixture.claimRequirements(1858), nil, + ) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrSettlementPending, se.ErrorReason) + assert.Equal(t, priorSig, se.Transaction) + assert.Empty(t, signer.sentTransactions()) + + stored, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + require.True(t, ok) + assert.Equal(t, priorSig, stored) +} + +// A malformed pending-store entry (e.g. corrupted by a non-conforming custom +// PendingSettlementStore implementation) must not permanently wedge the +// channel: awaitPendingUptoSignature drops it and surfaces a terminal error +// so a fresh settle attempt can proceed instead of retrying forever. +func TestClaimSettle_PendingSettlementStore_MalformedSignatureDropsEntry(t *testing.T) { + signer := newMockSigner(t, 1) + stub := newStubRPC(t) + fixture := newPaymentFixture(t, signer) + scheme := newScheme(signer, stub, nil) + stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) + key := settlementCacheKey(fixture.requirements.Network, fixture.channelID.String()) + require.NoError(t, scheme.pendingStore.Set(context.Background(), key, "not-valid-base58-!!!")) + + _, err := scheme.Settle( + context.Background(), fixture.claimPayload(t, 1858), fixture.claimRequirements(1858), nil, + ) + var se *x402.SettleError + require.True(t, errors.As(err, &se)) + assert.Equal(t, ErrChannelBroadcast, se.ErrorReason) + + _, ok, getErr := scheme.pendingStore.Get(context.Background(), key) + require.NoError(t, getErr) + assert.False(t, ok, "a malformed entry must be dropped, not retried forever") +} diff --git a/go/mechanisms/svm/upto/facilitator/rent_cleanup.go b/go/mechanisms/svm/upto/facilitator/rent_cleanup.go index d7e58b5044..111784d180 100644 --- a/go/mechanisms/svm/upto/facilitator/rent_cleanup.go +++ b/go/mechanisms/svm/upto/facilitator/rent_cleanup.go @@ -10,9 +10,7 @@ import ( "time" solana "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" - "github.com/x402-foundation/x402/go/v2/mechanisms/svm" "github.com/x402-foundation/x402/go/v2/mechanisms/svm/paymentchannels" "github.com/x402-foundation/x402/go/v2/mechanisms/svm/upto" ) @@ -163,14 +161,9 @@ func (c StartConfig) discoveryOptions() DiscoveryOptions { // RentCleanupConfig configures a rent cleanup manager for one network. type RentCleanupConfig struct { - Signer svm.FacilitatorSvmSigner + Signer UptoFacilitatorSigner Storage ChannelStorage Network string - RPCURL string - - // RPC is a prebuilt client used instead of building one from RPCURL, for - // callers that need custom headers, transport, or rate limiting. - RPC *rpc.Client // ComputeUnitPriceMicroLamports is the SetComputeUnitPrice (microlamports // per compute unit) attached to cleanup transactions; 0 omits the @@ -193,11 +186,9 @@ type RentCleanupConfig struct { // refunds the unsettled remainder to the client, so cleanup only kicks in // after the voucher deadline plus a grace period. type RentCleanupManager struct { - signer svm.FacilitatorSvmSigner + signer UptoFacilitatorSigner storage ChannelStorage network string - rpcURL string - rpc *rpc.Client computeUnitPriceMicroLamports *uint64 settleComputeUnitLimit *uint32 @@ -221,26 +212,19 @@ type RentCleanupManager struct { // NewRentCleanupManager creates a rent cleanup manager. It does not start // automatically; the facilitator scheme never runs cleanup on its own. func NewRentCleanupManager(config RentCleanupConfig) *RentCleanupManager { + signer := config.Signer + if signer == nil { + panic("upto svm rent cleanup: signer is required") + } return &RentCleanupManager{ - signer: config.Signer, + signer: signer, storage: config.Storage, network: config.Network, - rpcURL: config.RPCURL, - rpc: config.RPC, computeUnitPriceMicroLamports: config.ComputeUnitPriceMicroLamports, settleComputeUnitLimit: config.SettleComputeUnitLimit, } } -// rpcClient returns the injected client when there is one, otherwise builds -// one for the network. -func (m *RentCleanupManager) rpcClient() (*rpc.Client, error) { - if m.rpc != nil { - return m.rpc, nil - } - return upto.NewRPCClient(m.network, m.rpcURL) -} - // Start runs Cleanup on an interval, and Discover on its own longer interval // when one is configured, until Stop is called or the context is canceled. // Calling Start on a running manager is a no-op. @@ -331,10 +315,6 @@ func (m *RentCleanupManager) Cleanup(ctx context.Context, opts CleanupOptions) e opts = opts.withDefaults() - rpcClient, err := m.rpcClient() - if err != nil { - return err - } records, err := m.storage.List(ctx) if err != nil { return fmt.Errorf("failed to list stored channels: %w", err) @@ -367,7 +347,7 @@ func (m *RentCleanupManager) Cleanup(ctx context.Context, opts CleanupOptions) e opts.reportError(err, record.ChannelID) continue } - channel, exists, err := fetchChannelAccount(ctx, rpcClient, channelID) + channel, exists, err := fetchChannelAccount(ctx, m.signer, m.network, channelID) if err != nil { opts.reportError(err, record.ChannelID) continue @@ -399,7 +379,7 @@ func (m *RentCleanupManager) Cleanup(ctx context.Context, opts CleanupOptions) e ) continue } - signature, err := m.submitCloseOrDistribute(ctx, rpcClient, record, channel, channelID) + signature, err := m.submitCloseOrDistribute(ctx, record, channel, channelID) if err != nil { opts.reportError(err, record.ChannelID) continue @@ -417,10 +397,10 @@ func (m *RentCleanupManager) Cleanup(ctx context.Context, opts CleanupOptions) e Action: action, }) } - m.deleteIfGone(ctx, rpcClient, channelID, record.ChannelID, opts) + m.deleteIfGone(ctx, channelID, record.ChannelID, opts) case paymentchannels.StatusDistributed: - slot, err := m.currentSlot(ctx, rpcClient, ¤tSlot) + slot, err := m.currentSlot(ctx, ¤tSlot) if err != nil { opts.reportError(err, record.ChannelID) continue @@ -442,7 +422,7 @@ func (m *RentCleanupManager) Cleanup(ctx context.Context, opts CleanupOptions) e } } - m.submitReclaimBatches(ctx, rpcClient, reclaimCandidates, opts) + m.submitReclaimBatches(ctx, reclaimCandidates, opts) return nil } @@ -464,9 +444,9 @@ func (m *RentCleanupManager) Discover(ctx context.Context, opts DiscoveryOptions m.passMu.Lock() defer m.passMu.Unlock() - rpcClient, err := m.rpcClient() - if err != nil { - return err + querier := programAccountsQuerier{ + signer: assertProgramAccountsSigner(m.signer, "RentCleanupManager.Discover"), + network: m.network, } records, err := m.storage.List(ctx) if err != nil { @@ -485,12 +465,12 @@ func (m *RentCleanupManager) Discover(ctx context.Context, opts DiscoveryOptions if ctx.Err() != nil { return ctx.Err() } - found, err := paymentchannels.DiscoverChannelsByRentPayer(ctx, rpcClient, managed) + found, err := paymentchannels.DiscoverChannelsByRentPayer(ctx, querier, managed) if err != nil { opts.reportError(fmt.Errorf("discovery failed for rent payer %s: %w", managed, err), "") continue } - slot, err := m.currentSlot(ctx, rpcClient, ¤tSlot) + slot, err := m.currentSlot(ctx, ¤tSlot) if err != nil { return err } @@ -528,11 +508,11 @@ func (m *RentCleanupManager) Discover(ctx context.Context, opts DiscoveryOptions // currentSlot lazily fetches and caches the slot used to gate reclaims, so a // pass with no Distributed records never pays for the RPC round trip. -func (m *RentCleanupManager) currentSlot(ctx context.Context, rpcClient *rpc.Client, cache **uint64) (uint64, error) { +func (m *RentCleanupManager) currentSlot(ctx context.Context, cache **uint64) (uint64, error) { if *cache != nil { return **cache, nil } - slot, err := rpcClient.GetSlot(ctx, upto.SlotCommitment) + slot, err := m.signer.GetSlot(ctx, m.network, upto.SlotCommitment) if err != nil { return 0, fmt.Errorf("failed to fetch the current slot: %w", err) } @@ -578,7 +558,6 @@ func orderForScan(records []ChannelRecord, cursor string) []ChannelRecord { // distributes an already-Sealed one. func (m *RentCleanupManager) submitCloseOrDistribute( ctx context.Context, - rpcClient *rpc.Client, record ChannelRecord, channel *paymentchannels.Channel, channelID solana.PublicKey, @@ -618,7 +597,7 @@ func (m *RentCleanupManager) submitCloseOrDistribute( } } - return submitSettle(ctx, rpcClient, m.signer, feePayer, m.network, instructions, submitSettleOptions{ + return submitSettle(ctx, m.signer, feePayer, m.network, instructions, submitSettleOptions{ ComputeUnitLimit: m.settleComputeUnitLimit, ComputeUnitPriceMicroLamports: m.computeUnitPriceMicroLamports, }) @@ -634,7 +613,6 @@ func (m *RentCleanupManager) submitCloseOrDistribute( // pool. func (m *RentCleanupManager) submitReclaimBatches( ctx context.Context, - rpcClient *rpc.Client, candidates []reclaimCandidate, opts CleanupOptions, ) { @@ -658,7 +636,7 @@ func (m *RentCleanupManager) submitReclaimBatches( go func(rentPayer solana.PublicKey, group []reclaimCandidate) { defer wg.Done() budget := int64(opts.MaxTxsPerSigner) - m.submitReclaimGroup(ctx, rpcClient, rentPayer, group, opts, &budget) + m.submitReclaimGroup(ctx, rentPayer, group, opts, &budget) }(rentPayer, group) } wg.Wait() @@ -669,7 +647,6 @@ func (m *RentCleanupManager) submitReclaimBatches( // cannot overrun maxTxs. func (m *RentCleanupManager) submitReclaimGroup( ctx context.Context, - rpcClient *rpc.Client, rentPayer solana.PublicKey, group []reclaimCandidate, opts CleanupOptions, @@ -696,7 +673,7 @@ func (m *RentCleanupManager) submitReclaimGroup( if end > len(group) { end = len(group) } - batch := m.refreshReclaimBatch(ctx, rpcClient, group[start:end], opts) + batch := m.refreshReclaimBatch(ctx, group[start:end], opts) if len(batch) == 0 { continue } @@ -710,7 +687,7 @@ func (m *RentCleanupManager) submitReclaimGroup( } reclaimLimit := ReclaimComputeUnitLimit(len(batch)) - signature, err := submitSettle(ctx, rpcClient, m.signer, feePayer, m.network, instructions, submitSettleOptions{ + signature, err := submitSettle(ctx, m.signer, feePayer, m.network, instructions, submitSettleOptions{ ComputeUnitLimit: &reclaimLimit, ComputeUnitPriceMicroLamports: m.computeUnitPriceMicroLamports, }) @@ -737,13 +714,12 @@ func (m *RentCleanupManager) submitReclaimGroup( // the whole batch. func (m *RentCleanupManager) refreshReclaimBatch( ctx context.Context, - rpcClient *rpc.Client, batch []reclaimCandidate, opts CleanupOptions, ) []reclaimCandidate { live := make([]reclaimCandidate, 0, len(batch)) for _, candidate := range batch { - channel, exists, err := fetchChannelAccount(ctx, rpcClient, candidate.channelID) + channel, exists, err := fetchChannelAccount(ctx, m.signer, m.network, candidate.channelID) if err != nil { opts.reportError(err, candidate.channelID.String()) continue @@ -765,12 +741,11 @@ func (m *RentCleanupManager) refreshReclaimBatch( // deleteIfGone drops the storage entry once the channel account is closed. func (m *RentCleanupManager) deleteIfGone( ctx context.Context, - rpcClient *rpc.Client, channelID solana.PublicKey, storedID string, opts CleanupOptions, ) { - exists, err := channelExists(ctx, rpcClient, channelID) + exists, err := channelExists(ctx, m.signer, m.network, channelID) if err != nil { opts.reportError(err, storedID) return diff --git a/go/mechanisms/svm/upto/facilitator/rent_cleanup_test.go b/go/mechanisms/svm/upto/facilitator/rent_cleanup_test.go index 18498a0249..f2a24fda74 100644 --- a/go/mechanisms/svm/upto/facilitator/rent_cleanup_test.go +++ b/go/mechanisms/svm/upto/facilitator/rent_cleanup_test.go @@ -55,11 +55,11 @@ func newCleanupHarness(t *testing.T) *cleanupHarness { payer: payer.PublicKey(), payTo: payTo.PublicKey(), } + harness.signer.attachRPC(rpc.New(harness.stub.url)) harness.manager = NewRentCleanupManager(RentCleanupConfig{ Signer: signer, Storage: harness.storage, Network: testNetwork, - RPCURL: harness.stub.url, }) return harness } @@ -348,7 +348,6 @@ func TestCleanupUsesTheConfiguredSettleComputeBudget(t *testing.T) { Signer: harness.signer, Storage: harness.storage, Network: testNetwork, - RPCURL: harness.stub.url, SettleComputeUnitLimit: &settleLimit, ComputeUnitPriceMicroLamports: &price, }) @@ -822,7 +821,6 @@ func TestCleanupPropagatesAStorageListFailure(t *testing.T) { Signer: harness.signer, Storage: failingStorage{}, Network: testNetwork, - RPCURL: harness.stub.url, }) err := manager.Cleanup(context.Background(), harness.options(CleanupOptions{})) @@ -1164,12 +1162,12 @@ func TestDiscoverNeverOverwritesATrackedChannel(t *testing.T) { func TestSubmitReclaimBatchesRunsRentPayerGroupsConcurrently(t *testing.T) { harness := newCleanupHarness(t) signer := newMockSigner(t, 2) + signer.attachRPC(rpc.New(harness.stub.url)) harness.signer = signer harness.manager = NewRentCleanupManager(RentCleanupConfig{ Signer: signer, Storage: harness.storage, Network: testNetwork, - RPCURL: harness.stub.url, }) openSlot := testSlot - paymentchannels.OpenSlotWindow - 1 @@ -1225,12 +1223,12 @@ func TestSubmitReclaimBatchesRunsRentPayerGroupsConcurrently(t *testing.T) { func TestSubmitReclaimBatchesBudgetsEachRentPayerGroupIndependently(t *testing.T) { harness := newCleanupHarness(t) signer := newMockSigner(t, 2) + signer.attachRPC(rpc.New(harness.stub.url)) harness.signer = signer harness.manager = NewRentCleanupManager(RentCleanupConfig{ Signer: signer, Storage: harness.storage, Network: testNetwork, - RPCURL: harness.stub.url, }) openSlot := testSlot - paymentchannels.OpenSlotWindow - 1 @@ -1260,8 +1258,6 @@ func TestCleanupAndDiscoverPreferAnInjectedRPCClient(t *testing.T) { Signer: harness.signer, Storage: harness.storage, Network: testNetwork, - RPCURL: "http://127.0.0.1:1/unreachable", - RPC: rpc.New(harness.stub.url), }) openSlot := testSlot - paymentchannels.OpenSlotWindow - 1 diff --git a/go/mechanisms/svm/upto/facilitator/scheme.go b/go/mechanisms/svm/upto/facilitator/scheme.go index 45d40381bd..97d5da8b43 100644 --- a/go/mechanisms/svm/upto/facilitator/scheme.go +++ b/go/mechanisms/svm/upto/facilitator/scheme.go @@ -10,10 +10,10 @@ import ( "log" "math/rand/v2" "strconv" + "sync" "time" solana "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/mechanisms/svm" @@ -40,14 +40,6 @@ const StoragePhaseSettle StoragePhase = "settle" // Config is the optional configuration of the SVM `upto` facilitator. type Config struct { - // RPCURL overrides the per-network default RPC endpoint. - RPCURL string - - // RPC is a prebuilt client used instead of building one from RPCURL, for - // callers that need custom headers, transport, or rate limiting. It is - // also threaded into the manager NewRentCleanupManager returns. - RPC *rpc.Client - // ChannelStorage indexes sponsored channels for rent cleanup. Defaults to // in-memory storage; inject a durable one for multi-process facilitators. ChannelStorage ChannelStorage @@ -107,10 +99,11 @@ type Config struct { // abandoned channel to recover its rent, while any nonzero settlement still // requires the server's receiver-authorizer voucher. type UptoSvmScheme struct { - signer svm.FacilitatorSvmSigner + signer UptoFacilitatorSigner config Config channelStorage ChannelStorage settlementCache *svm.SettlementCache + pendingStore x402.PendingSettlementStore } // NewUptoSvmScheme creates a new UptoSvmScheme. The signer supplies the fee @@ -144,10 +137,21 @@ func NewUptoSvmScheme(signer svm.FacilitatorSvmSigner, config *Config) *UptoSvmS storage = NewInMemoryChannelStorage() } return &UptoSvmScheme{ - signer: signer, + signer: assertUptoFacilitatorSigner(signer, "UptoSvmScheme"), config: cfg, channelStorage: storage, settlementCache: svm.NewSettlementCache(), + pendingStore: x402.NewInMemoryPendingSettlementStore(), + } +} + +// SetPendingSettlementStore overrides the default in-memory PendingSettlementStore +// used to reconcile a deposit (open) or claim (settle_and_seal + distribute) +// transaction that broadcast successfully but whose confirmation wait timed +// out (settlement_pending). A nil store is a no-op. +func (f *UptoSvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store } } @@ -184,23 +188,11 @@ func (f *UptoSvmScheme) NewRentCleanupManager(network string) *RentCleanupManage Signer: f.signer, Storage: f.channelStorage, Network: network, - RPCURL: f.config.RPCURL, - RPC: f.config.RPC, ComputeUnitPriceMicroLamports: f.config.ComputeUnitPriceMicroLamports, SettleComputeUnitLimit: f.config.SettleComputeUnitLimit, }) } -// rpcClient returns the injected client when there is one, otherwise builds -// one for the network. Every RPC entry point in the scheme goes through here -// so an injected client is never bypassed. -func (f *UptoSvmScheme) rpcClient(network string) (*rpc.Client, error) { - if f.config.RPC != nil { - return f.config.RPC, nil - } - return upto.NewRPCClient(network, f.config.RPCURL) -} - // GetExtra advertises a randomly selected fee payer for payment-channel opens. // Random selection distributes load across the configured signers. func (f *UptoSvmScheme) GetExtra(network x402.Network) map[string]interface{} { @@ -290,6 +282,89 @@ func (f *UptoSvmScheme) Settle( "a partial settlement requires a receiver-authorizer voucher") } +// uptoDepositCacheKey and uptoClaimCacheKey return the PendingSettlementStore +// key for one phase of an upto channel. Deposit and claim are scoped +// separately so a pending deposit never blocks the later claim on the same +// channel. +func uptoDepositCacheKey(network, channelId string) string { + return fmt.Sprintf("upto:deposit:%s:%s", network, channelId) +} + +func uptoClaimCacheKey(network, channelId string) string { + return fmt.Sprintf("upto:%s:%s", network, channelId) +} + +// reconcilePendingUpto checks the PendingSettlementStore for a signature +// previously recorded under cacheKey by a broadcast that couldn't confirm in +// time, shared by the deposit and claim fast paths in settleDeposit and +// settleClaim. hit is false when there is nothing to reconcile (no store +// configured or no entry), telling the caller to fall through to full +// validation; hit is true whenever a reconciliation attempt was made, +// regardless of whether it succeeded. +func (f *UptoSvmScheme) reconcilePendingUpto( + ctx context.Context, + cacheKey string, + payer string, + amountStr string, + network x402.Network, + networkStr string, +) (resp *x402.SettleResponse, hit bool, err error) { + if f.pendingStore == nil { + return nil, false, nil + } + sigStr, ok, _ := f.pendingStore.Get(ctx, cacheKey) + if !ok { + return nil, false, nil + } + // Remove before reconciling (rather than after) so a concurrent retry of + // the same payload misses here instead of also reconciling: it falls + // through to the settlementCache dedup check, which independently rejects + // it as a duplicate. + _ = f.pendingStore.Delete(ctx, cacheKey) + if err := f.awaitPendingUptoSignature(ctx, cacheKey, sigStr, payer, network, networkStr); err != nil { + return nil, true, err + } + return &x402.SettleResponse{ + Success: true, + Transaction: sigStr, + Network: network, + Amount: amountStr, + Payer: payer, + }, true, nil +} + +// awaitPendingUptoSignature re-awaits confirmation of a signature previously +// recorded in the PendingSettlementStore under cacheKey, without +// re-verifying, re-signing, or re-broadcasting. Re-broadcasting is not a safe +// fallback here: the deposit's channel PDA is one-shot (a second open would +// hit ErrChannelAlreadyOpen) and a claim seals the channel (a second claim +// attempt would hit a channel-no-longer-open verification failure) — either +// of which would misreport an already-successful payment as failed. Returns +// nil on confirmation (with the store entry cleared); on failure it +// re-records the pending entry and returns the settlement_pending +// x402.SettleError to surface. +func (f *UptoSvmScheme) awaitPendingUptoSignature( + ctx context.Context, + cacheKey string, + sigStr string, + payer string, + network x402.Network, + networkStr string, +) error { + signature, err := solana.SignatureFromBase58(sigStr) + if err != nil { + // Malformed cache entry — drop it so future attempts fall through to + // the normal broadcast path instead of getting stuck. + _ = f.pendingStore.Delete(ctx, cacheKey) + return x402.NewSettleError(ErrChannelBroadcast, payer, network, "", err.Error()) + } + if err := f.signer.ConfirmTransaction(ctx, signature, networkStr); err != nil { + return svm.RecordPendingOrTerminal(ctx, f.pendingStore, cacheKey, sigStr, payer, network, ErrTransactionFailed, err) + } + _ = f.pendingStore.Delete(ctx, cacheKey) + return nil +} + // settleDeposit validates the open authorization, simulates the whole channel // lifecycle, then co-signs and broadcasts the open. func (f *UptoSvmScheme) settleDeposit( @@ -299,6 +374,18 @@ func (f *UptoSvmScheme) settleDeposit( ) (*x402.SettleResponse, error) { network := x402.Network(payload.Accepted.Network) + // Pending-settlement fast path: a prior deposit settle for this exact + // channel broadcast the open successfully but couldn't confirm it in + // time. Reconcile against that signature instead of re-validating and + // re-broadcasting — a second open attempt would hit ErrChannelAlreadyOpen + // even though the original payment is (or will be) fine. + if uptoPayload, parseErr := svm.UptoPayloadFromMap(payload.Payload); parseErr == nil { + depositKey := uptoDepositCacheKey(string(requirements.Network), uptoPayload.ChannelId) + if resp, hit, err := f.reconcilePendingUpto(ctx, depositKey, uptoPayload.From, uptoPayload.MaxAmount, network, string(requirements.Network)); hit { + return resp, err + } + } + auth, err := f.validateOpenAuthorization(ctx, payload, requirements) if err != nil { verifyErr := &x402.VerifyError{} @@ -309,15 +396,12 @@ func (f *UptoSvmScheme) settleDeposit( } uptoPayload := auth.payload - rpcClient, err := f.rpcClient(string(requirements.Network)) - if err != nil { - return nil, x402.NewSettleError(ErrPaymentRequirements, uptoPayload.From, network, "", err.Error()) - } + networkStr := string(requirements.Network) // One authorization opens one channel. An existing PDA is a replay or a // stranded prior open, not a rebind: a handler failure after a successful // deposit refunds through the zero-amount cancel settle instead. - exists, err := channelExists(ctx, rpcClient, auth.channelID) + exists, err := channelExists(ctx, f.signer, networkStr, auth.channelID) if err != nil { return nil, x402.NewSettleError(ErrChannelState, uptoPayload.From, network, "", err.Error()) } @@ -329,7 +413,7 @@ func (f *UptoSvmScheme) settleDeposit( // Two concurrent deposit settles can both observe a missing channel and // both broadcast the same open. The key is deposit-scoped so it does not // block the later claim on the same channel. - depositKey := fmt.Sprintf("upto:deposit:%s:%s", requirements.Network, uptoPayload.ChannelId) + depositKey := uptoDepositCacheKey(string(requirements.Network), uptoPayload.ChannelId) if f.settlementCache.IsDuplicate(depositKey) { return nil, x402.NewSettleError(ErrDuplicateSettlement, uptoPayload.From, network, "", "a deposit settlement for this channel is already in flight") @@ -346,7 +430,7 @@ func (f *UptoSvmScheme) settleDeposit( Splits: auth.channelConfig.Splits, } if err := simulateOpenSettleDistribute( - ctx, rpcClient, f.signer, auth.feePayer, uptoPayload.OpenTransaction, simChannel, + ctx, f.signer, auth.feePayer, uptoPayload.OpenTransaction, simChannel, ); err != nil { f.settlementCache.Delete(depositKey) return nil, x402.NewSettleError(ErrSettlementSimulation, uptoPayload.From, network, "", err.Error()) @@ -373,11 +457,19 @@ func (f *UptoSvmScheme) settleDeposit( ctx, f.signer, auth.feePayer, string(requirements.Network), uptoPayload.OpenTransaction, ) if err != nil { + // A non-empty signature means the open broadcast successfully but + // ConfirmTransaction couldn't observe confirmation in time: leave the + // deposit dedup lock in place (a fresh broadcast would double-open) + // and record the signature so a retry reconciles via the fast path + // above instead of re-validating. + if openSignature != "" { + return nil, svm.RecordPendingOrTerminal(ctx, f.pendingStore, depositKey, openSignature, uptoPayload.From, network, ErrChannelBroadcast, err) + } f.settlementCache.Delete(depositKey) return nil, x402.NewSettleError(ErrChannelBroadcast, uptoPayload.From, network, "", err.Error()) } - if _, err := fetchAndVerifyOpenChannel(ctx, rpcClient, auth.channelID, expectedOpenChannel{ + if _, err := fetchAndVerifyOpenChannel(ctx, f.signer, networkStr, auth.channelID, expectedOpenChannel{ AuthorizedSigner: auth.channelConfig.ReceiverAuthorizer, Mint: requirements.Asset, Payee: auth.channelConfig.FeePayer, @@ -412,6 +504,18 @@ func (f *UptoSvmScheme) settleClaim( ) (*x402.SettleResponse, error) { network := x402.Network(payload.Accepted.Network) + // Pending-settlement fast path: a prior claim settle for this exact + // channel broadcast settle_and_seal + distribute successfully but + // couldn't confirm it in time. Reconcile against that signature instead + // of re-verifying and re-submitting — the channel is sealed by a + // successful settle_and_seal, so a second claim attempt would fail + // fetchAndVerifyOpenChannel's "channel is not open" check even though the + // original payment succeeded. + settlementKey := uptoClaimCacheKey(string(requirements.Network), uptoPayload.ChannelId) + if resp, hit, err := f.reconcilePendingUpto(ctx, settlementKey, uptoPayload.From, strconv.FormatUint(actual, 10), network, string(requirements.Network)); hit { + return resp, err + } + if uptoPayload.VoucherSignature == "" { return nil, x402.NewSettleError(ErrMissingVoucher, uptoPayload.From, network, "", "voucherSignature is present but empty") @@ -457,12 +561,9 @@ func (f *UptoSvmScheme) settleClaim( if err != nil { return nil, x402.NewSettleError(ErrPaymentRequirements, uptoPayload.From, network, "", err.Error()) } - rpcClient, err := f.rpcClient(string(requirements.Network)) - if err != nil { - return nil, x402.NewSettleError(ErrPaymentRequirements, uptoPayload.From, network, "", err.Error()) - } - channel, err := fetchAndVerifyOpenChannel(ctx, rpcClient, channelID, expectedOpenChannel{ + networkStr := string(requirements.Network) + expected := expectedOpenChannel{ AuthorizedSigner: channelConfig.ReceiverAuthorizer, Mint: requirements.Asset, Payee: channelConfig.FeePayer, @@ -471,30 +572,66 @@ func (f *UptoSvmScheme) settleClaim( Deposit: maxAmount, GracePeriod: channelConfig.WithdrawDelay, Splits: channelConfig.Splits, - }) - if err != nil { - return nil, x402.NewSettleError(ErrChannelState, uptoPayload.From, network, "", err.Error()) + } + + var ( + channel *verifiedOpenChannel + prefetchedHash solana.Hash + channelErr error + blockhashErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { + defer wg.Done() + channel, channelErr = fetchAndVerifyOpenChannel(ctx, f.signer, networkStr, channelID, expected) + }() + go func() { + defer wg.Done() + prefetchedHash, _, blockhashErr = f.signer.GetLatestBlockhash(ctx, networkStr) + }() + wg.Wait() + if channelErr != nil { + return nil, x402.NewSettleError(ErrChannelState, uptoPayload.From, network, "", channelErr.Error()) + } + if blockhashErr != nil { + return nil, x402.NewSettleError(ErrPaymentRequirements, uptoPayload.From, network, "", blockhashErr.Error()) } // Deduplicate only once the channel is rebound, so replays and concurrent // claims — including ones carrying a different valid voucher — collapse to // a single settle_and_seal + distribute. Failures above never insert. - settlementKey := fmt.Sprintf("upto:%s:%s", requirements.Network, uptoPayload.ChannelId) if f.settlementCache.IsDuplicate(settlementKey) { return nil, x402.NewSettleError(ErrDuplicateSettlement, uptoPayload.From, network, "", "a settlement for this channel is already in flight") } - signature, err := f.submitClaim(ctx, rpcClient, feePayer, channel, claimArgs{ - Network: string(requirements.Network), + signature, err := f.submitClaim(ctx, feePayer, channel, claimArgs{ + Network: networkStr, TokenProgram: tokenProgram, Actual: actual, ExpiresAt: uptoPayload.ExpiresAt, VoucherSignature: uptoPayload.VoucherSignature, - }) + }, &prefetchedHash) if err != nil { + var simErr *SettlementSimulationError + if errors.As(err, &simErr) { + f.settlementCache.Delete(settlementKey) + return nil, x402.NewSettleError(ErrSettlementSimulation, uptoPayload.From, network, "", simErr.Error()) + } + // A non-empty signature means settle_and_seal + distribute broadcast + // successfully but ConfirmTransaction couldn't observe confirmation + // in time: leave the settlement dedup lock in place (a fresh submit + // would double-seal) and record the signature so a retry reconciles + // via the fast path above instead of re-verifying. + if signature != "" { + return nil, svm.RecordPendingOrTerminal(ctx, f.pendingStore, settlementKey, signature, uptoPayload.From, network, ErrTransactionFailed, err) + } f.settlementCache.Delete(settlementKey) - return nil, x402.NewSettleError(ErrTransactionFailed, uptoPayload.From, network, signature, err.Error()) + return nil, x402.NewSettleError(ErrTransactionFailed, uptoPayload.From, network, "", err.Error()) + } + if f.pendingStore != nil { + _ = f.pendingStore.Delete(ctx, settlementKey) } // Settlement is confirmed onchain past this point, so storage bookkeeping @@ -527,10 +664,10 @@ type claimArgs struct { func (f *UptoSvmScheme) submitClaim( ctx context.Context, - rpcClient *rpc.Client, feePayer solana.PublicKey, channel *verifiedOpenChannel, args claimArgs, + prefetchedBlockhash *solana.Hash, ) (string, error) { // The program requires settled < cumulative_amount, so a zero charge seals // without a voucher. The zero-amount voucher still authenticated this @@ -552,10 +689,14 @@ func (f *UptoSvmScheme) submitClaim( return "", err } - return submitSettle(ctx, rpcClient, f.signer, feePayer, args.Network, instructions, submitSettleOptions{ + opts := submitSettleOptions{ ComputeUnitLimit: f.config.SettleComputeUnitLimit, ComputeUnitPriceMicroLamports: f.config.ComputeUnitPriceMicroLamports, - }) + } + if prefetchedBlockhash != nil { + opts.LatestBlockhash = prefetchedBlockhash + } + return submitSettle(ctx, f.signer, feePayer, args.Network, instructions, opts) } // openAuthorization is the validated open-authorization context shared by @@ -790,11 +931,7 @@ func (f *UptoSvmScheme) resolveRecentSlot( return slot, nil } - rpcClient, err := f.rpcClient(string(requirements.Network)) - if err != nil { - return 0, err - } - slot, err := rpcClient.GetSlot(ctx, upto.SlotCommitment) + slot, err := f.signer.GetSlot(ctx, string(requirements.Network), upto.SlotCommitment) if err != nil { return 0, fmt.Errorf("failed to fetch the current slot: %w", err) } diff --git a/go/mechanisms/svm/upto/facilitator/scheme_test.go b/go/mechanisms/svm/upto/facilitator/scheme_test.go index 595db8557a..b7d8179db6 100644 --- a/go/mechanisms/svm/upto/facilitator/scheme_test.go +++ b/go/mechanisms/svm/upto/facilitator/scheme_test.go @@ -22,10 +22,7 @@ import ( // newScheme wires a facilitator against the stub RPC. func newScheme(signer *mockSigner, stub *stubRPC, config *Config) *UptoSvmScheme { - if config == nil { - config = &Config{} - } - config.RPCURL = stub.url + signer.attachRPC(rpc.New(stub.url)) return NewUptoSvmScheme(signer, config) } @@ -584,11 +581,6 @@ func TestSettleReadsEachRPCClassAtThePolicyCommitment(t *testing.T) { want: upto.SlotCommitment, reason: "the openSlot anchor must sit in the frame the client pinned", }, - { - method: "getLatestBlockhash", - want: upto.BlockhashCommitment, - reason: "a finalized blockhash cannot be dropped by a fork", - }, { method: "simulateTransaction", want: upto.StateCommitment, @@ -1258,38 +1250,6 @@ func TestNewRentCleanupManagerSharesTheSchemeStorage(t *testing.T) { assert.Same(t, storage, injected.NewRentCleanupManager(testNetwork).storage) } -// An injected client is how a facilitator routes sends through its own paced -// or instrumented transport, so it has to win over RPCURL everywhere, not just -// on the settle path. -func TestAnInjectedRPCClientIsPreferredOverTheURL(t *testing.T) { - signer := newMockSigner(t, 1) - stub := newStubRPC(t) - fixture := newPaymentFixture(t, signer) - scheme := NewUptoSvmScheme(signer, &Config{ - RPCURL: "http://127.0.0.1:1/unreachable", - RPC: rpc.New(stub.url), - }) - signer.onSend = func(*solana.Transaction) { - stub.setAccount(fixture.channelID.String(), fixture.openChannel().encode(t)) - } - - response, err := scheme.Settle(context.Background(), fixture.payload, fixture.requirements, nil) - require.NoError(t, err) - assert.True(t, response.Success, "the settle reached the stub, so the unreachable URL was never dialed") - - assert.Same(t, scheme.config.RPC, scheme.NewRentCleanupManager(testNetwork).rpc, - "the manager inherits the scheme's client") -} - -func TestTheSchemeBuildsAClientFromTheURLWhenNoneIsInjected(t *testing.T) { - scheme := newScheme(newMockSigner(t, 1), newStubRPC(t), nil) - - client, err := scheme.rpcClient(testNetwork) - require.NoError(t, err) - assert.NotNil(t, client) - assert.Nil(t, scheme.NewRentCleanupManager(testNetwork).rpc) -} - // failingStorage rejects every write so storage-failure paths can be exercised. type failingStorage struct{} diff --git a/go/mechanisms/svm/upto/facilitator/signer.go b/go/mechanisms/svm/upto/facilitator/signer.go new file mode 100644 index 0000000000..bdce4e0c48 --- /dev/null +++ b/go/mechanisms/svm/upto/facilitator/signer.go @@ -0,0 +1,99 @@ +package facilitator + +import ( + "context" + "fmt" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + "github.com/x402-foundation/x402/go/v2/mechanisms/svm/paymentchannels" +) + +// UptoFacilitatorSigner is FacilitatorSvmSigner narrowed to the read and +// simulate RPC the `upto` facilitator requires at runtime. Exact-only signers +// omit the extra methods. GetProgramAccounts is optional and only needed for +// RentCleanupManager.Discover(); assertProgramAccountsSigner checks it. +type UptoFacilitatorSigner interface { + svm.FacilitatorSvmSigner + + GetAccountInfo( + ctx context.Context, + account solana.PublicKey, + network string, + opts *rpc.GetAccountInfoOpts, + ) (*rpc.GetAccountInfoResult, error) + GetLatestBlockhash(ctx context.Context, network string) (solana.Hash, uint64, error) + GetSlot(ctx context.Context, network string, commitment rpc.CommitmentType) (uint64, error) + SimulateTransactionWithOpts( + ctx context.Context, + tx *solana.Transaction, + network string, + opts *rpc.SimulateTransactionOpts, + ) error +} + +// assertUptoFacilitatorSigner validates that signer exposes every RPC cap the +// `upto` facilitator needs at construction. Panics with a clear message when a +// required method is missing. +func assertUptoFacilitatorSigner(signer svm.FacilitatorSvmSigner, label string) UptoFacilitatorSigner { + type accountInfoGetter interface { + GetAccountInfo(context.Context, solana.PublicKey, string, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) + } + type blockhashGetter interface { + GetLatestBlockhash(context.Context, string) (solana.Hash, uint64, error) + } + type slotGetter interface { + GetSlot(context.Context, string, rpc.CommitmentType) (uint64, error) + } + type simWithOpts interface { + SimulateTransactionWithOpts(context.Context, *solana.Transaction, string, *rpc.SimulateTransactionOpts) error + } + + if _, ok := signer.(accountInfoGetter); !ok { + panic(fmt.Sprintf("%s requires GetAccountInfo on the signer", label)) + } + if _, ok := signer.(blockhashGetter); !ok { + panic(fmt.Sprintf("%s requires GetLatestBlockhash on the signer", label)) + } + if _, ok := signer.(slotGetter); !ok { + panic(fmt.Sprintf("%s requires GetSlot on the signer", label)) + } + if _, ok := signer.(simWithOpts); !ok { + panic(fmt.Sprintf("%s requires SimulateTransactionWithOpts on the signer", label)) + } + return signer.(UptoFacilitatorSigner) +} + +// programAccountsGetter is the optional discovery sweep cap. It is not part of +// UptoFacilitatorSigner so NewUptoSvmScheme can construct without it. +type programAccountsGetter interface { + GetProgramAccounts( + context.Context, + string, + solana.PublicKey, + *rpc.GetProgramAccountsOpts, + ) (rpc.GetProgramAccountsResult, error) +} + +// assertProgramAccountsSigner validates GetProgramAccounts for discovery sweeps. +func assertProgramAccountsSigner(signer svm.FacilitatorSvmSigner, label string) programAccountsGetter { + gpa, ok := signer.(programAccountsGetter) + if !ok { + panic(fmt.Sprintf("%s requires GetProgramAccounts on the signer", label)) + } + return gpa +} + +type programAccountsQuerier struct { + signer programAccountsGetter + network string +} + +func (q programAccountsQuerier) GetProgramAccounts( + ctx context.Context, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + return q.signer.GetProgramAccounts(ctx, q.network, paymentchannels.ProgramID, opts) +} diff --git a/go/mechanisms/svm/upto/facilitator/signer_test.go b/go/mechanisms/svm/upto/facilitator/signer_test.go new file mode 100644 index 0000000000..028b41d066 --- /dev/null +++ b/go/mechanisms/svm/upto/facilitator/signer_test.go @@ -0,0 +1,74 @@ +package facilitator + +import ( + "context" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/assert" + + "github.com/x402-foundation/x402/go/v2/mechanisms/svm" +) + +// schemeOnlySigner implements every UptoFacilitatorSigner method and omits +// GetProgramAccounts, so construction succeeds and Discover panics. +type schemeOnlySigner struct{} + +func (schemeOnlySigner) GetAddresses(context.Context, string) []solana.PublicKey { + return nil +} + +func (schemeOnlySigner) SignTransaction(context.Context, *solana.Transaction, solana.PublicKey, string) error { + return nil +} + +func (schemeOnlySigner) SimulateTransaction(context.Context, *solana.Transaction, string) error { + return nil +} + +func (schemeOnlySigner) SendTransaction(context.Context, *solana.Transaction, string) (solana.Signature, error) { + return solana.Signature{}, nil +} + +func (schemeOnlySigner) ConfirmTransaction(context.Context, solana.Signature, string) error { + return nil +} + +func (schemeOnlySigner) GetAccountInfo(context.Context, solana.PublicKey, string, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) { + return nil, nil +} + +func (schemeOnlySigner) GetLatestBlockhash(context.Context, string) (solana.Hash, uint64, error) { + return solana.Hash{}, 0, nil +} + +func (schemeOnlySigner) GetSlot(context.Context, string, rpc.CommitmentType) (uint64, error) { + return 0, nil +} + +func (schemeOnlySigner) SimulateTransactionWithOpts(context.Context, *solana.Transaction, string, *rpc.SimulateTransactionOpts) error { + return nil +} + +var ( + _ svm.FacilitatorSvmSigner = schemeOnlySigner{} + _ UptoFacilitatorSigner = schemeOnlySigner{} +) + +func TestNewUptoSvmSchemeAllowsSignerWithoutGetProgramAccounts(t *testing.T) { + assert.NotPanics(t, func() { + NewUptoSvmScheme(schemeOnlySigner{}, nil) + }) +} + +func TestDiscoverRequiresGetProgramAccounts(t *testing.T) { + manager := NewRentCleanupManager(RentCleanupConfig{ + Signer: schemeOnlySigner{}, + Storage: NewInMemoryChannelStorage(), + Network: testNetwork, + }) + assert.PanicsWithValue(t, "RentCleanupManager.Discover requires GetProgramAccounts on the signer", func() { + _ = manager.Discover(context.Background(), DiscoveryOptions{}) + }) +} diff --git a/go/mechanisms/svm/upto/facilitator/utils_test.go b/go/mechanisms/svm/upto/facilitator/utils_test.go index 53566b4725..cde87ae312 100644 --- a/go/mechanisms/svm/upto/facilitator/utils_test.go +++ b/go/mechanisms/svm/upto/facilitator/utils_test.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strconv" @@ -36,6 +37,7 @@ type mockSigner struct { keys []solana.PrivateKey sent []*solana.Transaction + rpc *rpc.Client // sendErr, when set, fails every broadcast. sendErr error @@ -127,6 +129,71 @@ func (s *mockSigner) ConfirmTransaction(_ context.Context, _ solana.Signature, _ return s.confirmErr } +func (s *mockSigner) attachRPC(client *rpc.Client) { + s.rpc = client +} + +func (s *mockSigner) GetAccountInfo( + ctx context.Context, + account solana.PublicKey, + _ string, + opts *rpc.GetAccountInfoOpts, +) (*rpc.GetAccountInfoResult, error) { + if s.rpc == nil { + return nil, errors.New("mock signer has no RPC client") + } + return s.rpc.GetAccountInfoWithOpts(ctx, account, opts) +} + +func (s *mockSigner) GetLatestBlockhash(ctx context.Context, _ string) (solana.Hash, uint64, error) { + if s.rpc == nil { + return solana.Hash{}, 0, errors.New("mock signer has no RPC client") + } + latest, err := s.rpc.GetLatestBlockhash(ctx, upto.BlockhashCommitment) + if err != nil { + return solana.Hash{}, 0, err + } + return latest.Value.Blockhash, latest.Value.LastValidBlockHeight, nil +} + +func (s *mockSigner) GetSlot(ctx context.Context, _ string, commitment rpc.CommitmentType) (uint64, error) { + if s.rpc == nil { + return 0, errors.New("mock signer has no RPC client") + } + return s.rpc.GetSlot(ctx, commitment) +} + +func (s *mockSigner) SimulateTransactionWithOpts( + ctx context.Context, + tx *solana.Transaction, + _ string, + opts *rpc.SimulateTransactionOpts, +) error { + if s.rpc == nil { + return errors.New("mock signer has no RPC client") + } + result, err := s.rpc.SimulateTransactionWithOpts(ctx, tx, opts) + if err != nil { + return fmt.Errorf("settlement simulation failed: %w", err) + } + if result != nil && result.Value != nil && result.Value.Err != nil { + return fmt.Errorf("settlement simulation failed: %v", result.Value.Err) + } + return nil +} + +func (s *mockSigner) GetProgramAccounts( + ctx context.Context, + _ string, + programID solana.PublicKey, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + if s.rpc == nil { + return nil, errors.New("mock signer has no RPC client") + } + return s.rpc.GetProgramAccountsWithOpts(ctx, programID, opts) +} + func (s *mockSigner) sentTransactions() []*solana.Transaction { s.mu.Lock() defer s.mu.Unlock() diff --git a/go/mechanisms/svm/utils.go b/go/mechanisms/svm/utils.go index fd3f1d7400..ce9681fa74 100644 --- a/go/mechanisms/svm/utils.go +++ b/go/mechanisms/svm/utils.go @@ -283,7 +283,10 @@ func GetTokenPayerFromTransaction(tx *solana.Transaction) (string, error) { // Iterate through instructions to find TransferChecked for _, inst := range tx.Message.Instructions { - programID := tx.Message.AccountKeys[inst.ProgramIDIndex] + programID, err := tx.Message.Program(inst.ProgramIDIndex) + if err != nil { + continue + } // Check if this is a token program instruction if programID == solana.TokenProgramID || programID == solana.Token2022ProgramID { diff --git a/go/pending_settlement_store.go b/go/pending_settlement_store.go new file mode 100644 index 0000000000..7722f19d05 --- /dev/null +++ b/go/pending_settlement_store.go @@ -0,0 +1,124 @@ +package x402 + +import ( + "context" + "sync" + "time" +) + +// PendingSettlementTTL is the TTL applied by the default in-memory +// PendingSettlementStore implementation. A store implementation backed by a +// different mechanism (e.g. Redis, for a multi-instance facilitator) is free +// to use its own TTL — this constant only governs InMemoryPendingSettlementStore. +const PendingSettlementTTL = 5 * time.Minute + +// PendingSettlementStore lets a facilitator-side mechanism remember a +// broadcast-but-not-yet-confirmed transaction hash, keyed by a deterministic +// identifier derived from the payment payload (e.g. an EIP-3009/Permit2 +// signature, or an SVM message hash). When a settle attempt's receipt/ +// confirmation wait fails, the mechanism stores the broadcast hash here before +// returning a `settlement_pending` error. On a subsequent settle attempt for +// the same payload (typically the resource server's single automatic retry — +// see x402ResourceServer.settle), the mechanism checks this store first and, +// on a hit, reconciles against the already-broadcast transaction instead of +// verifying and broadcasting a second one. +// +// This is an interface — not a concrete type — specifically so a +// multi-instance facilitator (running several replicas with no session +// affinity) can supply a shared, network-backed implementation (e.g. Redis) +// instead of the in-memory default, which only works when a retry happens to +// land back on the same process. Implementations must be safe for concurrent +// use. +type PendingSettlementStore interface { + // Get returns the previously stored transaction hash for key, if any. + // ok is false when there is no entry (including one that has expired). + Get(ctx context.Context, key string) (txHash string, ok bool, err error) + // Set records that key's payment broadcast txHash but has not yet been + // confirmed. A subsequent Set for the same key overwrites the prior value. + Set(ctx context.Context, key string, txHash string) error + // Delete removes any pending entry for key, e.g. once the transaction is + // confirmed (success) or the mechanism determines it terminally failed. + Delete(ctx context.Context, key string) error +} + +// pendingSettlementEntry is a single InMemoryPendingSettlementStore record. +type pendingSettlementEntry struct { + txHash string + storedAt time.Time +} + +// InMemoryPendingSettlementStore is the default PendingSettlementStore +// implementation: a mutex-protected, per-process map with lazy TTL pruning +// (mirrors the shape of go/mechanisms/svm/settlement_cache.go). It never +// performs network I/O — Get additionally prunes expired entries (O(n) in +// the number of currently-stored entries, which stays small since entries +// only exist while a settlement is genuinely pending), so every call adds no +// meaningful latency to the settle hot path. Suitable for single-instance +// facilitators; multi-instance deployments should inject a shared, +// network-backed PendingSettlementStore implementation instead (e.g. Redis). +type InMemoryPendingSettlementStore struct { + mu sync.Mutex + entries map[string]pendingSettlementEntry +} + +// NewInMemoryPendingSettlementStore creates a new, empty +// InMemoryPendingSettlementStore. +func NewInMemoryPendingSettlementStore() *InMemoryPendingSettlementStore { + return &InMemoryPendingSettlementStore{ + entries: make(map[string]pendingSettlementEntry), + } +} + +// Get implements PendingSettlementStore. +func (s *InMemoryPendingSettlementStore) Get(_ context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.prune() + + entry, ok := s.entries[key] + if !ok { + return "", false, nil + } + return entry.txHash, true, nil +} + +// Set implements PendingSettlementStore. +func (s *InMemoryPendingSettlementStore) Set(_ context.Context, key string, txHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.entries[key] = pendingSettlementEntry{txHash: txHash, storedAt: time.Now()} + return nil +} + +// Delete implements PendingSettlementStore. +func (s *InMemoryPendingSettlementStore) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.entries, key) + return nil +} + +// Entries returns a snapshot of the underlying map — use only in tests. +func (s *InMemoryPendingSettlementStore) Entries() map[string]string { + s.mu.Lock() + defer s.mu.Unlock() + + out := make(map[string]string, len(s.entries)) + for k, v := range s.entries { + out[k] = v.txHash + } + return out +} + +// prune removes entries older than PendingSettlementTTL. Caller must hold mu. +func (s *InMemoryPendingSettlementStore) prune() { + cutoff := time.Now().Add(-PendingSettlementTTL) + for key, entry := range s.entries { + if entry.storedAt.Before(cutoff) { + delete(s.entries, key) + } + } +} diff --git a/go/pending_settlement_store_test.go b/go/pending_settlement_store_test.go new file mode 100644 index 0000000000..2634fd9a13 --- /dev/null +++ b/go/pending_settlement_store_test.go @@ -0,0 +1,223 @@ +package x402 + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInMemoryPendingSettlementStore_GetMiss(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + + txHash, ok, err := store.Get(context.Background(), "missing-key") + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, txHash) +} + +func TestInMemoryPendingSettlementStore_SetThenGetReturnsStoredValue(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + + require.NoError(t, store.Set(context.Background(), "key-1", "0xabc")) + + txHash, ok, err := store.Get(context.Background(), "key-1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "0xabc", txHash) +} + +func TestInMemoryPendingSettlementStore_SetOverwritesPriorValueForSameKey(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + + require.NoError(t, store.Set(context.Background(), "key-1", "0xabc")) + require.NoError(t, store.Set(context.Background(), "key-1", "0xdef")) + + txHash, ok, err := store.Get(context.Background(), "key-1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "0xdef", txHash) +} + +func TestInMemoryPendingSettlementStore_DeleteRemovesEntry(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "key-1", "0xabc")) + + require.NoError(t, store.Delete(context.Background(), "key-1")) + + _, ok, err := store.Get(context.Background(), "key-1") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestInMemoryPendingSettlementStore_DeleteOnMissingKeyIsANoOp(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + + assert.NoError(t, store.Delete(context.Background(), "never-existed")) + + _, ok, err := store.Get(context.Background(), "never-existed") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestInMemoryPendingSettlementStore_DistinctKeysDoNotCollide(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + + require.NoError(t, store.Set(context.Background(), "key-a", "0xaaa")) + require.NoError(t, store.Set(context.Background(), "key-b", "0xbbb")) + + txA, okA, _ := store.Get(context.Background(), "key-a") + txB, okB, _ := store.Get(context.Background(), "key-b") + assert.True(t, okA) + assert.Equal(t, "0xaaa", txA) + assert.True(t, okB) + assert.Equal(t, "0xbbb", txB) + + require.NoError(t, store.Delete(context.Background(), "key-a")) + _, okA, _ = store.Get(context.Background(), "key-a") + assert.False(t, okA) + txB, okB, _ = store.Get(context.Background(), "key-b") + assert.True(t, okB) + assert.Equal(t, "0xbbb", txB) +} + +// backdate rewrites key's storedAt timestamp directly (bypassing Set, which +// always stamps "now") so tests can simulate an entry that is already older +// than PendingSettlementTTL without sleeping. +func backdate(store *InMemoryPendingSettlementStore, key string, age time.Duration) { + store.mu.Lock() + defer store.mu.Unlock() + entry, ok := store.entries[key] + if !ok { + return + } + entry.storedAt = time.Now().Add(-age) + store.entries[key] = entry +} + +func TestInMemoryPendingSettlementStore_EntriesOlderThanTTLArePrunedOnGet(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "expired", "0xabc")) + backdate(store, "expired", PendingSettlementTTL+time.Second) + + _, ok, err := store.Get(context.Background(), "expired") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestInMemoryPendingSettlementStore_FreshEntriesSurvivePruning(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "expired", "0xabc")) + require.NoError(t, store.Set(context.Background(), "fresh", "0xdef")) + backdate(store, "expired", PendingSettlementTTL+time.Second) + + // Triggers a prune pass as a side effect of Get(). + _, expiredOk, _ := store.Get(context.Background(), "expired") + freshTx, freshOk, _ := store.Get(context.Background(), "fresh") + + assert.False(t, expiredOk) + assert.True(t, freshOk) + assert.Equal(t, "0xdef", freshTx) +} + +func TestInMemoryPendingSettlementStore_KeepsAnEntryThatHasNotYetReachedTheTTL(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "key-1", "0xabc")) + backdate(store, "key-1", PendingSettlementTTL-time.Second) + + txHash, ok, err := store.Get(context.Background(), "key-1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "0xabc", txHash) +} + +func TestInMemoryPendingSettlementStore_SetRefreshesAnExistingKeysTimestamp(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "key-1", "0xabc")) + backdate(store, "key-1", PendingSettlementTTL-time.Second) + + // Refresh: a subsequent Set must reset storedAt to now. + require.NoError(t, store.Set(context.Background(), "key-1", "0xdef")) + backdate(store, "key-1", PendingSettlementTTL-time.Second) + + txHash, ok, err := store.Get(context.Background(), "key-1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "0xdef", txHash) +} + +func TestInMemoryPendingSettlementStore_EntriesReturnsATxHashOnlySnapshot(t *testing.T) { + store := NewInMemoryPendingSettlementStore() + require.NoError(t, store.Set(context.Background(), "key-a", "0xaaa")) + require.NoError(t, store.Set(context.Background(), "key-b", "0xbbb")) + + snapshot := store.Entries() + + assert.Equal(t, map[string]string{"key-a": "0xaaa", "key-b": "0xbbb"}, snapshot) +} + +// recordingPendingSettlementStore proves mechanism code depending on +// PendingSettlementStore only needs the interface, never the concrete +// InMemoryPendingSettlementStore type. Mirrors the TS/Python test doubles of +// the same name. +type recordingPendingSettlementStore struct { + getCalls []string + setCalls []string + deleteCalls []string + entries map[string]string +} + +func newRecordingPendingSettlementStore() *recordingPendingSettlementStore { + return &recordingPendingSettlementStore{entries: make(map[string]string)} +} + +func (s *recordingPendingSettlementStore) Get(_ context.Context, key string) (string, bool, error) { + s.getCalls = append(s.getCalls, key) + txHash, ok := s.entries[key] + return txHash, ok, nil +} + +func (s *recordingPendingSettlementStore) Set(_ context.Context, key string, txHash string) error { + s.setCalls = append(s.setCalls, key) + s.entries[key] = txHash + return nil +} + +func (s *recordingPendingSettlementStore) Delete(_ context.Context, key string) error { + s.deleteCalls = append(s.deleteCalls, key) + delete(s.entries, key) + return nil +} + +func TestPendingSettlementStoreInterface_CustomImplementationBehavesLikeDefault(t *testing.T) { + var store PendingSettlementStore = newRecordingPendingSettlementStore() + + _, ok, err := store.Get(context.Background(), "k") + require.NoError(t, err) + assert.False(t, ok) + + require.NoError(t, store.Set(context.Background(), "k", "0x1")) + txHash, ok, err := store.Get(context.Background(), "k") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "0x1", txHash) + + require.NoError(t, store.Delete(context.Background(), "k")) + _, ok, err = store.Get(context.Background(), "k") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestPendingSettlementStoreInterface_RecordsInteractionsDistinctlyFromTheInMemoryImplementation(t *testing.T) { + store := newRecordingPendingSettlementStore() + + require.NoError(t, store.Set(context.Background(), "k", "0x1")) + _, _, _ = store.Get(context.Background(), "k") + require.NoError(t, store.Delete(context.Background(), "k")) + + assert.Equal(t, []string{"k"}, store.setCalls) + assert.Equal(t, []string{"k"}, store.getCalls) + assert.Equal(t, []string{"k"}, store.deleteCalls) +} diff --git a/go/server.go b/go/server.go index 2173d9ab50..cc8b5a4d28 100644 --- a/go/server.go +++ b/go/server.go @@ -8,7 +8,6 @@ import ( "log" "math/big" "regexp" - "strconv" "strings" "sync" "time" @@ -29,13 +28,19 @@ var ( func ResolveSettlementOverrideAmount(rawAmount string, requirements types.PaymentRequirements, decimals int) (string, error) { if m := percentRegex.FindStringSubmatch(rawAmount); m != nil { parts := strings.SplitN(m[1], ".", 2) - intPart, _ := strconv.ParseInt(parts[0], 10, 64) - decPart := int64(0) + integerPart, ok := new(big.Int).SetString(parts[0], 10) + if !ok { + return "", fmt.Errorf("invalid percent amount: %s", rawAmount) + } + fractionalPart := new(big.Int) if len(parts) == 2 { padded := (parts[1] + "00")[:2] - decPart, _ = strconv.ParseInt(padded, 10, 64) + if _, ok := fractionalPart.SetString(padded, 10); !ok { + return "", fmt.Errorf("invalid percent amount: %s", rawAmount) + } } - scaledPercent := big.NewInt(intPart*100 + decPart) + scaledPercent := new(big.Int).Mul(integerPart, big.NewInt(100)) + scaledPercent.Add(scaledPercent, fractionalPart) base, ok := new(big.Int).SetString(requirements.Amount, 10) if !ok { return "", fmt.Errorf("invalid requirements amount: %s", requirements.Amount) @@ -903,6 +908,53 @@ func asStringAnyMap(v interface{}) (map[string]interface{}, bool) { return nil, false } +// normalizeJSONValue converts typed Go values to their generic wire shape. +func normalizeJSONValue(v interface{}) interface{} { + encoded, err := json.Marshal(v) + if err != nil { + return v + } + var decoded interface{} + if err := json.Unmarshal(encoded, &decoded); err != nil { + return v + } + return decoded +} + +// extensionInfo returns the normalized info envelope when present, otherwise +// the normalized extension value itself. +func extensionInfo(v interface{}) interface{} { + normalized := normalizeJSONValue(v) + if extension, ok := asStringAnyMap(normalized); ok { + if info, has := extension["info"]; has { + return info + } + } + return normalized +} + +// serverOwnedInfoFieldsMatch reports whether every server-owned field supplied +// by the client was independently declared by the resource server with the same +// value. Undeclared extensions are treated as having no server-owned fields. +func serverOwnedInfoFieldsMatch(advertised, echoed interface{}, fields map[string]struct{}) bool { + echoedMap, ok := asStringAnyMap(echoed) + if !ok { + return true + } + advertisedMap, _ := asStringAnyMap(advertised) + for field := range fields { + echoedValue, present := echoedMap[field] + if !present { + continue + } + advertisedValue, declared := advertisedMap[field] + if !declared || !DeepEqual(advertisedValue, echoedValue) { + return false + } + } + return true +} + // ExtensionValidationResult is returned by ValidateExtensions. Valid is true // when the client either omitted extensions or echoed every server-advertised // field; otherwise InvalidReason/ExtensionKey describe the mismatch. @@ -915,7 +967,8 @@ type ExtensionValidationResult struct { // ValidateExtensions checks that the client-echoed extension info preserves the // server-advertised subset for every key the server declared. Clients may add // fields and may omit extension keys entirely, but may not drop or change a -// server-advertised value. +// server-advertised value. Fields listed in serverOwnedInfoFields may not be +// added without a matching declaration. func (s *x402ResourceServer) ValidateExtensions( serverExtensions map[string]interface{}, payload types.PaymentPayload, @@ -923,7 +976,7 @@ func (s *x402ResourceServer) ValidateExtensions( if payload.X402Version != 2 { return ExtensionValidationResult{Valid: true} } - if len(serverExtensions) == 0 || len(payload.Extensions) == 0 { + if len(payload.Extensions) == 0 { return ExtensionValidationResult{Valid: true} } @@ -941,40 +994,24 @@ func (s *x402ResourceServer) ValidateExtensions( field string } - // normalize converts a server-declared value (which may be a typed struct) - // into the generic JSON shape the echoed payload already uses. - // Falls back to the original value when it is not JSON-encodable. - normalize := func(v interface{}) interface{} { - encoded, err := json.Marshal(v) - if err != nil { - return v - } - var decoded interface{} - if err := json.Unmarshal(encoded, &decoded); err != nil { - return v - } - return decoded - } - for key, echoedValue := range payload.Extensions { serverValue, declared := serverExtensions[key] + advertisedInfo := extensionInfo(serverValue) + echoedInfo := extensionInfo(echoedValue) + fields := serverOwnedInfoFields[key] if !declared { + if len(fields) > 0 && !serverOwnedInfoFieldsMatch(advertisedInfo, echoedInfo, fields) { + return ExtensionValidationResult{ + Valid: false, + InvalidReason: "extension_echo_mismatch", + ExtensionKey: key, + } + } continue } - // Compare the `info` envelope when present, otherwise the flat value. - advertised := normalize(serverValue) - if m, ok := advertised.(map[string]interface{}); ok { - if info, has := m["info"]; has { - advertised = info - } - } - echoed := echoedValue - if m, ok := echoedValue.(map[string]interface{}); ok { - if info, has := m["info"]; has { - echoed = info - } - } + advertised := advertisedInfo + echoed := echoedInfo // Exclude fields the extension regenerates per response (e.g. nonces) // so a fresh server value is not flagged against the client's echo. @@ -1036,7 +1073,7 @@ func (s *x402ResourceServer) ValidateExtensions( } } - if mismatch { + if mismatch || (len(fields) > 0 && !serverOwnedInfoFieldsMatch(advertisedInfo, echoedInfo, fields)) { return ExtensionValidationResult{ Valid: false, InvalidReason: "extension_echo_mismatch", @@ -1071,6 +1108,14 @@ var additiveArrayMaxLengths = map[string]map[string]int{ "builder-code": {"s": 10}, } +// serverOwnedInfoFields lists extension info fields, keyed by extension key, +// that clients may echo only when the resource server independently declared +// the same value. Core cannot import extension packages, so these identifiers +// are duplicated here alongside additiveArrayInfoFields. +var serverOwnedInfoFields = map[string]map[string]struct{}{ + "builder-code": {"a": {}}, +} + // dynamicInfoFields returns the dynamic `info` field names declared by the // registered extension for `key`, or nil when the extension is unknown or does // not opt into dynamic-field handling. @@ -1297,6 +1342,60 @@ func (s *x402ResourceServer) runAfterVerifyHooks( return verifyResult, nil } +// settleWithPendingRetry calls facilitator.Settle once, then retries exactly +// once with the identical payload/requirements bytes when the outcome is a +// non-terminal settlement_pending failure carrying a broadcast transaction +// hash. This sits above all scheme/network dispatch — the mechanism that +// actually handles the retry (via its own PendingSettlementStore check, see +// go/pending_settlement_store.go) reconciles against the already-broadcast +// transaction instead of verifying and broadcasting a second one. No +// mutation, backoff, or sleep: the mechanism layer owns any bounded waiting. +// Any other outcome (success, or a different failure reason) short-circuits +// after the first call. Capped at exactly one retry regardless of the second +// outcome, so this can never loop. +func settleWithPendingRetry( + ctx context.Context, + facilitator FacilitatorClient, + payloadBytes []byte, + requirementsBytes []byte, +) (*SettleResponse, error) { + settleResult, settleErr := facilitator.Settle(ctx, payloadBytes, requirementsBytes) + if !isRetryableSettlementPending(settleResult, settleErr) { + return settleResult, settleErr + } + return facilitator.Settle(ctx, payloadBytes, requirementsBytes) +} + +// isRetryableSettlementPending reports whether a settle outcome is a +// retryable settlement_pending: either a thrown *SettleError (the local/ +// in-process FacilitatorClient path) or a returned SettleResponse with +// success:false (the HTTP/remote FacilitatorClient path), in both cases with +// errorReason=="settlement_pending" and a non-empty transaction hash. +func isRetryableSettlementPending(result *SettleResponse, err error) bool { + if err != nil { + var se *SettleError + if errors.As(err, &se) { + return se.ErrorReason == ErrSettlementPending && se.Transaction != "" + } + return false + } + return result != nil && !result.Success && + result.ErrorReason == ErrSettlementPending && result.Transaction != "" +} + +// settleResponseToError synthesizes an error from a returned success:false +// SettleResponse so it can flow through the same SettleFailureContext/ +// OnSettleFailureHook path as a thrown *SettleError. Mirrors Python's +// `Exception(settle_result.error_reason or "Settlement failed")` fallback +// (python/x402/server_base.py). +func settleResponseToError(result *SettleResponse) error { + reason := result.ErrorReason + if reason == "" { + reason = "Settlement failed" + } + return NewSettleError(reason, result.Payer, result.Network, result.Transaction, result.ErrorMessage) +} + // SettlePayment settles a V2 payment with no declared extensions. // Equivalent to SettlePaymentWithExtensions(ctx, payload, requirements, overrides, nil, SettlePhaseAfterHandler). func (s *x402ResourceServer) SettlePayment(ctx context.Context, payload types.PaymentPayload, requirements types.PaymentRequirements, overrides *SettlementOverrides) (*SettleResponse, error) { @@ -1459,9 +1558,9 @@ func (s *x402ResourceServer) SettlePaymentWithExtensions( return nil, NewSettleError("failed_to_marshal_payload", "", Network(effectiveRequirements.Network), "", err.Error()) } - settleResult, settleErr := facilitator.Settle(ctx, payloadBytes, requirementsBytes) + settleResult, settleErr := settleWithPendingRetry(ctx, facilitator, payloadBytes, requirementsBytes) - // Handle failure + // Handle failure (thrown error from a local/in-process facilitator). if settleErr != nil { failureCtx := SettleFailureContext{SettleContext: hookCtx, Error: settleErr} for _, lh := range settleFailureHooks { @@ -1473,6 +1572,22 @@ func (s *x402ResourceServer) SettlePaymentWithExtensions( return settleResult, settleErr } + // A returned (non-thrown) settleResult with success:false — e.g. from a + // remote/HTTP facilitator — silently looked like a success before this + // check: afterSettle hooks would run and callers would treat the response + // as settled. Route it through onSettleFailure like a thrown error so + // hooks get a chance to recover, matching the Python SDK. + if settleResult != nil && !settleResult.Success { + failureCtx := SettleFailureContext{SettleContext: hookCtx, Error: settleResponseToError(settleResult)} + for _, lh := range settleFailureHooks { + result, _ := lh.Hook(failureCtx) + if result != nil && result.Recovered { + return result.Result, nil + } + } + return settleResult, nil + } + // Execute afterSettle hooks resultCtx := SettleResultContext{SettleContext: hookCtx, Result: settleResult} for _, lh := range afterSettleHooks { diff --git a/go/server_settlement_pending_retry_test.go b/go/server_settlement_pending_retry_test.go new file mode 100644 index 0000000000..ce534cd67a --- /dev/null +++ b/go/server_settlement_pending_retry_test.go @@ -0,0 +1,474 @@ +package x402 + +import ( + "context" + "errors" + "testing" + + "github.com/x402-foundation/x402/go/v2/types" +) + +// Unit tests for the resource server's single automatic settle retry on +// settlement_pending (isRetryableSettlementPending / settleWithPendingRetry, +// server.go), and for the success:false -> onSettleFailure routing fix. +// Mirrors TS's x402ResourceServer.settlementPending.test.ts and Python's +// test_settle_pending_retry.py. + +func pendingSettleResponse(transaction string) *SettleResponse { + return &SettleResponse{ + Success: false, + ErrorReason: ErrSettlementPending, + Transaction: transaction, + Network: "eip155:8453", + } +} + +func successSettleResponse(transaction string) *SettleResponse { + return &SettleResponse{Success: true, Transaction: transaction, Network: "eip155:8453"} +} + +func terminalFailureSettleResponse(reason string) *SettleResponse { + return &SettleResponse{Success: false, ErrorReason: reason, Transaction: "", Network: "eip155:8453"} +} + +func TestIsRetryableSettlementPending_ReturnedResult(t *testing.T) { + tests := []struct { + name string + result *SettleResponse + want bool + }{ + {"pending with transaction is retryable", pendingSettleResponse("0xabc"), true}, + {"success is not retryable", successSettleResponse("0xabc"), false}, + {"other failure reason is not retryable", terminalFailureSettleResponse("invalid_signature"), false}, + {"pending without transaction is not retryable", pendingSettleResponse(""), false}, + {"nil result is not retryable", nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRetryableSettlementPending(tt.result, nil); got != tt.want { + t.Errorf("isRetryableSettlementPending(%+v, nil) = %v, want %v", tt.result, got, tt.want) + } + }) + } +} + +func TestIsRetryableSettlementPending_ThrownError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + "thrown SettleError with pending reason and transaction is retryable", + NewSettleError(ErrSettlementPending, "0xpayer", "eip155:8453", "0xabc", ""), + true, + }, + { + "thrown SettleError with pending reason but no transaction is not retryable", + NewSettleError(ErrSettlementPending, "0xpayer", "eip155:8453", "", ""), + false, + }, + { + "thrown SettleError with a different reason is not retryable", + NewSettleError("invalid_signature", "0xpayer", "eip155:8453", "0xabc", ""), + false, + }, + { + "a non-SettleError is not retryable", + errors.New("boom"), + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // A thrown error always wins over any returned result, so the result + // argument is irrelevant here; pass nil to isolate the error path. + if got := isRetryableSettlementPending(nil, tt.err); got != tt.want { + t.Errorf("isRetryableSettlementPending(nil, %v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// queueFacilitatorClient serves canned (response, error) pairs from settleResponses/ +// settleErrors in order and records every call's raw bytes, so tests can assert +// both the call count and that a retry resends byte-identical payload/requirements. +type queueFacilitatorClient struct { + settleResponses []*SettleResponse + settleErrors []error + settleCalls [][2][]byte +} + +func (c *queueFacilitatorClient) Settle(_ context.Context, payloadBytes []byte, requirementsBytes []byte) (*SettleResponse, error) { + c.settleCalls = append(c.settleCalls, [2][]byte{payloadBytes, requirementsBytes}) + i := len(c.settleCalls) - 1 + var err error + if i < len(c.settleErrors) { + err = c.settleErrors[i] + } + var resp *SettleResponse + if i < len(c.settleResponses) { + resp = c.settleResponses[i] + } + return resp, err +} + +func (c *queueFacilitatorClient) Verify(_ context.Context, _ []byte, _ []byte) (*VerifyResponse, error) { + return &VerifyResponse{IsValid: true}, nil +} + +func (c *queueFacilitatorClient) GetSupported(_ context.Context) (SupportedResponse, error) { + return SupportedResponse{Kinds: []SupportedKind{{X402Version: 2, Scheme: "exact", Network: "eip155:8453"}}}, nil +} + +func TestSettleWithPendingRetry_NoRetryOnSuccess(t *testing.T) { + client := &queueFacilitatorClient{settleResponses: []*SettleResponse{successSettleResponse("0x1")}} + + result, err := settleWithPendingRetry(context.Background(), client, []byte("payload"), []byte("reqs")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Fatal("expected success") + } + if len(client.settleCalls) != 1 { + t.Fatalf("expected 1 settle call, got %d", len(client.settleCalls)) + } +} + +func TestSettleWithPendingRetry_NoRetryOnTerminalFailure(t *testing.T) { + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{terminalFailureSettleResponse("invalid_signature")}, + } + + result, err := settleWithPendingRetry(context.Background(), client, []byte("payload"), []byte("reqs")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success || result.ErrorReason != "invalid_signature" { + t.Fatalf("expected terminal failure to pass through unchanged, got %+v", result) + } + if len(client.settleCalls) != 1 { + t.Fatalf("expected 1 settle call, got %d", len(client.settleCalls)) + } +} + +func TestSettleWithPendingRetry_SingleRetryOnPendingThenSuccess(t *testing.T) { + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{pendingSettleResponse("0xpending"), successSettleResponse("0xconfirmed")}, + } + + result, err := settleWithPendingRetry(context.Background(), client, []byte("payload"), []byte("reqs")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success || result.Transaction != "0xconfirmed" { + t.Fatalf("expected retried success, got %+v", result) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected 2 settle calls, got %d", len(client.settleCalls)) + } +} + +func TestSettleWithPendingRetry_RetriesOnThrownSettlementPendingError(t *testing.T) { + client := &queueFacilitatorClient{ + settleErrors: []error{NewSettleError(ErrSettlementPending, "0xpayer", "eip155:8453", "0xpending", "")}, + settleResponses: []*SettleResponse{nil, successSettleResponse("0xpending")}, + } + + result, err := settleWithPendingRetry(context.Background(), client, []byte("payload"), []byte("reqs")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Fatalf("expected retried success, got %+v", result) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected 2 settle calls, got %d", len(client.settleCalls)) + } +} + +func TestSettleWithPendingRetry_CappedAtOneRetryWhenSecondAttemptAlsoPending(t *testing.T) { + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{pendingSettleResponse("0xfirst"), pendingSettleResponse("0xsecond")}, + } + + result, err := settleWithPendingRetry(context.Background(), client, []byte("payload"), []byte("reqs")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success || result.Transaction != "0xsecond" { + t.Fatalf("expected the second (still-pending) response returned as-is, got %+v", result) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected exactly 2 settle calls (no third attempt), got %d", len(client.settleCalls)) + } +} + +func TestSettleWithPendingRetry_RetriesWithByteIdenticalPayloadAndRequirements(t *testing.T) { + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{pendingSettleResponse("0xpending"), successSettleResponse("0x1")}, + } + payloadBytes := []byte(`{"a":1}`) + requirementsBytes := []byte(`{"b":2}`) + + _, err := settleWithPendingRetry(context.Background(), client, payloadBytes, requirementsBytes) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected 2 settle calls, got %d", len(client.settleCalls)) + } + if string(client.settleCalls[0][0]) != string(client.settleCalls[1][0]) { + t.Error("expected identical payload bytes on both attempts") + } + if string(client.settleCalls[0][1]) != string(client.settleCalls[1][1]) { + t.Error("expected identical requirements bytes on both attempts") + } +} + +// ============================================================================ +// End-to-end x402ResourceServer.SettlePayment retry routing +// ============================================================================ + +func settlementPendingTestFixture() (types.PaymentRequirements, types.PaymentPayload) { + requirements := types.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Asset: "USDC", + Amount: "1000000", + PayTo: "0xrecipient", + } + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: requirements, + Payload: map[string]interface{}{}, + } + return requirements, payload +} + +func TestSettlePayment_RetriesOnceOnReturnedSettlementPendingThenSucceeds(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{pendingSettleResponse("0xpending"), successSettleResponse("0xconfirmed")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success || result.Transaction != "0xconfirmed" { + t.Fatalf("expected retried success, got %+v", result) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected 2 settle calls, got %d", len(client.settleCalls)) + } +} + +func TestSettlePayment_DoesNotRetryOnNonPendingFailure(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{terminalFailureSettleResponse("invalid_signature")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success || result.ErrorReason != "invalid_signature" { + t.Fatalf("expected non-pending failure to pass through unchanged, got %+v", result) + } + if len(client.settleCalls) != 1 { + t.Fatalf("expected 1 settle call, got %d", len(client.settleCalls)) + } +} + +func TestSettlePayment_DoesNotRetryOnPendingWithEmptyTransaction(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{settleResponses: []*SettleResponse{pendingSettleResponse("")}} + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success { + t.Fatalf("expected failure, got %+v", result) + } + if len(client.settleCalls) != 1 { + t.Fatalf("expected 1 settle call, got %d", len(client.settleCalls)) + } +} + +func TestSettlePayment_CappedRetryStillPendingRoutesThroughOnSettleFailure(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{pendingSettleResponse("0xfirst"), pendingSettleResponse("0xsecond")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + hookCalls := 0 + server.OnSettleFailure(func(ctx SettleFailureContext) (*SettleFailureHookResult, error) { + hookCalls++ + return nil, nil + }) + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success || result.Transaction != "0xsecond" { + t.Fatalf("expected the still-pending retry result, got %+v", result) + } + if hookCalls != 1 { + t.Fatalf("expected onSettleFailure to run exactly once, got %d", hookCalls) + } + if len(client.settleCalls) != 2 { + t.Fatalf("expected exactly 2 settle calls (no third attempt), got %d", len(client.settleCalls)) + } +} + +func TestSettlePayment_SuccessFalseRoutesThroughOnSettleFailureAndRecovers(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{terminalFailureSettleResponse("invalid_signature")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + recovered := successSettleResponse("0xrecovered") + var capturedErr error + server.OnSettleFailure(func(ctx SettleFailureContext) (*SettleFailureHookResult, error) { + capturedErr = ctx.Error + return &SettleFailureHookResult{Recovered: true, Result: recovered}, nil + }) + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != recovered { + t.Fatalf("expected the recovered result, got %+v", result) + } + if capturedErr == nil || capturedErr.Error() == "" { + t.Fatal("expected onSettleFailure to receive a non-empty synthesized error") + } +} + +func TestSettlePayment_SuccessFalseReturnedAsIsWhenNoHookRecovers(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{terminalFailureSettleResponse("invalid_signature")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + hookCalls := 0 + server.OnSettleFailure(func(ctx SettleFailureContext) (*SettleFailureHookResult, error) { + hookCalls++ + return nil, nil + }) + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hookCalls != 1 { + t.Fatalf("expected onSettleFailure to run exactly once, got %d", hookCalls) + } + if result.Success || result.ErrorReason != "invalid_signature" { + t.Fatalf("expected the original failure returned unchanged, got %+v", result) + } +} + +func TestSettlePayment_DoesNotRunAfterSettleHooksOnFinalFailure(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{ + settleResponses: []*SettleResponse{terminalFailureSettleResponse("invalid_signature")}, + } + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + afterSettleCalls := 0 + server.OnAfterSettle(func(ctx SettleResultContext) error { + afterSettleCalls++ + return nil + }) + requirements, payload := settlementPendingTestFixture() + + if _, err := server.SettlePayment(ctx, payload, requirements, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if afterSettleCalls != 0 { + t.Fatalf("expected afterSettle not to run on a success:false result, got %d calls", afterSettleCalls) + } +} + +func TestSettlePayment_SuccessTrueRunsAfterSettleNotOnSettleFailure(t *testing.T) { + ctx := context.Background() + client := &queueFacilitatorClient{settleResponses: []*SettleResponse{successSettleResponse("0x1")}} + server := Newx402ResourceServer(WithFacilitatorClient(client)) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize server: %v", err) + } + afterSettleCalls, onSettleFailureCalls := 0, 0 + server.OnAfterSettle(func(ctx SettleResultContext) error { + afterSettleCalls++ + return nil + }) + server.OnSettleFailure(func(ctx SettleFailureContext) (*SettleFailureHookResult, error) { + onSettleFailureCalls++ + return nil, nil + }) + requirements, payload := settlementPendingTestFixture() + + result, err := server.SettlePayment(ctx, payload, requirements, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Fatalf("expected success, got %+v", result) + } + if afterSettleCalls != 1 { + t.Fatalf("expected afterSettle to run once, got %d", afterSettleCalls) + } + if onSettleFailureCalls != 0 { + t.Fatalf("expected onSettleFailure not to run, got %d", onSettleFailureCalls) + } +} diff --git a/go/server_test.go b/go/server_test.go index 91a60ec324..75d439523b 100644 --- a/go/server_test.go +++ b/go/server_test.go @@ -1206,11 +1206,18 @@ func TestResolveSettlementOverrideAmount(t *testing.T) { expected string }{ {"50%", "2000", "1000"}, + {"12.5%", "2000", "250"}, + {"12.50%", "2000", "250"}, {"100%", "2000", "2000"}, {"0%", "2000", "0"}, {"25%", "2000", "500"}, {"33.33%", "3000", "999"}, {"10.5%", "1000", "105"}, + {"50%", "3", "1"}, + {"999999999999999999999%", "1000000", "9999999999999999999990000"}, + {"92233720368547758.08%", "1000000", "922337203685477580800"}, + {"184467440737095516.16%", "1000000", "1844674407370955161600"}, + {"50%", "18446744073709551616", "9223372036854775808"}, } for _, tt := range tests { reqs := types.PaymentRequirements{Amount: tt.amount} @@ -1224,6 +1231,14 @@ func TestResolveSettlementOverrideAmount(t *testing.T) { } }) + t.Run("percent format with invalid requirements amount", func(t *testing.T) { + reqs := types.PaymentRequirements{Amount: "not-a-number"} + _, err := ResolveSettlementOverrideAmount("50%", reqs, 6) + if err == nil { + t.Fatal("expected invalid requirements amount error") + } + }) + t.Run("dollar price with default 6 decimals", func(t *testing.T) { tests := []struct { input string @@ -1319,6 +1334,51 @@ func TestValidateExtensions(t *testing.T) { } }) + t.Run("fails when undeclared builder-code carries app attribution", func(t *testing.T) { + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "attacker_app"}, + }, + }) + r := server.ValidateExtensions(nil, p) + if r.Valid || r.InvalidReason != "extension_echo_mismatch" || r.ExtensionKey != "builder-code" { + t.Fatalf("expected echo mismatch on undeclared builder-code app attribution, got %+v", r) + } + }) + + t.Run("fails when another extension is declared but builder-code app attribution is not", func(t *testing.T) { + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "attacker_app"}, + }, + }) + r := server.ValidateExtensions(serverExtensions, p) + if r.Valid || r.InvalidReason != "extension_echo_mismatch" || r.ExtensionKey != "builder-code" { + t.Fatalf("expected echo mismatch on undeclared builder-code app attribution, got %+v", r) + } + }) + + t.Run("passes when undeclared builder-code carries only client service attribution", func(t *testing.T) { + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"s": []interface{}{"client_service"}}, + }, + }) + if r := server.ValidateExtensions(nil, p); !r.Valid { + t.Fatalf("expected valid client-only service attribution, got %+v", r) + } + }) + + t.Run("fails when flat undeclared builder-code carries app attribution", func(t *testing.T) { + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{"a": "attacker_app"}, + }) + r := server.ValidateExtensions(nil, p) + if r.Valid || r.InvalidReason != "extension_echo_mismatch" || r.ExtensionKey != "builder-code" { + t.Fatalf("expected echo mismatch on flat undeclared builder-code app attribution, got %+v", r) + } + }) + t.Run("passes when client omits extensions", func(t *testing.T) { if r := server.ValidateExtensions(serverExtensions, payloadWith(nil)); !r.Valid { t.Fatalf("expected valid, got %+v", r) @@ -1390,6 +1450,20 @@ func TestValidateExtensions(t *testing.T) { } }) + t.Run("passes for v1 payloads with forged builder-code app attribution", func(t *testing.T) { + p := types.PaymentPayload{ + X402Version: 1, + Extensions: map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "forged_app"}, + }, + }, + } + if r := server.ValidateExtensions(nil, p); !r.Valid { + t.Fatalf("expected v1 extensions to remain outside echo validation, got %+v", r) + } + }) + // Extensions declared as typed Go structs (e.g. eip2612gassponsor.Extension) // must validate the same way as map-declared extensions. Mirrors the gas // extension shape inline to avoid an import cycle (eip2612gassponsor imports @@ -1425,6 +1499,42 @@ func TestValidateExtensions(t *testing.T) { } }) + t.Run("passes with struct-declared builder-code and matching echo", func(t *testing.T) { + type builderInfo struct { + AppCode string `json:"a"` + } + type builderExtension struct { + Info builderInfo `json:"info"` + } + advertised := map[string]interface{}{ + "builder-code": builderExtension{Info: builderInfo{AppCode: "honest_app"}}, + } + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "honest_app"}, + }, + }) + if r := server.ValidateExtensions(advertised, p); !r.Valid { + t.Fatalf("expected matching typed builder-code declaration to pass, got %+v", r) + } + }) + + t.Run("fails when typed undeclared builder-code carries app attribution", func(t *testing.T) { + type builderInfo struct { + AppCode string `json:"a"` + } + type builderExtension struct { + Info builderInfo `json:"info"` + } + p := payloadWith(map[string]interface{}{ + "builder-code": builderExtension{Info: builderInfo{AppCode: "attacker_app"}}, + }) + r := server.ValidateExtensions(nil, p) + if r.Valid || r.InvalidReason != "extension_echo_mismatch" || r.ExtensionKey != "builder-code" { + t.Fatalf("expected echo mismatch on typed undeclared builder-code app attribution, got %+v", r) + } + }) + t.Run("fails when struct-declared field is dropped", func(t *testing.T) { p := payloadWith(map[string]interface{}{ "eip2612GasSponsoring": map[string]interface{}{ @@ -1507,6 +1617,23 @@ func TestValidateExtensions(t *testing.T) { } }) + t.Run("fails when builder-code app attribution differs from the declaration", func(t *testing.T) { + advertised := map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "honest_app"}, + }, + } + p := payloadWith(map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "attacker_app"}, + }, + }) + r := server.ValidateExtensions(advertised, p) + if r.Valid || r.InvalidReason != "extension_echo_mismatch" || r.ExtensionKey != "builder-code" { + t.Fatalf("expected echo mismatch on builder-code app attribution, got %+v", r) + } + }) + t.Run("fails when echoed array drops an advertised element", func(t *testing.T) { advertised := map[string]interface{}{ "builder-code": map[string]interface{}{ @@ -1645,3 +1772,32 @@ func TestValidateExtensions(t *testing.T) { } }) } + +func TestVerifyPaymentWithExtensionsRejectsUndeclaredBuilderCodeAppAttribution(t *testing.T) { + server := Newx402ResourceServer() + payload := types.PaymentPayload{ + X402Version: 2, + Extensions: map[string]interface{}{ + "builder-code": map[string]interface{}{ + "info": map[string]interface{}{"a": "attacker_app"}, + }, + }, + } + + result, err := server.VerifyPaymentWithExtensions( + context.Background(), + payload, + types.PaymentRequirements{}, + nil, + ) + if err == nil { + t.Fatal("expected undeclared builder-code app attribution to fail verification") + } + if result == nil || result.IsValid || result.InvalidReason != "extension_echo_mismatch" { + t.Fatalf("expected invalid extension echo result, got %+v", result) + } + var verifyErr *VerifyError + if !errors.As(err, &verifyErr) || verifyErr.InvalidReason != "extension_echo_mismatch" { + t.Fatalf("expected extension_echo_mismatch verify error, got %v", err) + } +} diff --git a/go/test/integration/evm_batch_settlement_test.go b/go/test/integration/evm_batch_settlement_test.go index 580e881081..8c7c67ed90 100644 --- a/go/test/integration/evm_batch_settlement_test.go +++ b/go/test/integration/evm_batch_settlement_test.go @@ -20,6 +20,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "math/big" @@ -1191,3 +1192,134 @@ func TestBatchSettlementIntegration_RefundRecoverableRetryExhaustion(t *testing. } t.Logf("retry exhaustion observed: %v", err) } + +// ---------------------------------------------------------------------------- +// Scenario 11: deposit settlement-pending reconciliation (settlement-pending +// auto-recovery) +// ---------------------------------------------------------------------------- + +// TestBatchSettlementIntegration_DepositSettlementPendingReconciliation +// exercises the settlement-pending-auto-recovery mechanism layer against a +// real on-chain batch-settlement deposit: the first settle broadcasts a real +// deposit transaction but is forced (via forcedPendingReceiptSigner) to fail +// its receipt wait, producing a settlement_pending SettleError with the +// broadcast hash attached and a PendingSettlementStore entry populated +// (keyed on the deposit authorization signature). A second settle with the +// identical payload, now with receipt-waiting no longer forced to fail, must +// hit the pending-store fast path (skip verify/re-broadcast) and reconcile +// against that already-broadcast transaction — succeeding once it actually +// confirms on-chain, and critically with the SAME transaction hash as the +// first attempt, proving no second deposit was ever broadcast. +func TestBatchSettlementIntegration_DepositSettlementPendingReconciliation(t *testing.T) { + keys := loadBatchedTestKeys(t) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + clientEthClient, err := ethclient.Dial(keys.rpcURL) + if err != nil { + t.Fatalf("dial client RPC: %v", err) + } + clientSigner, err := evmsigners.NewClientSignerFromPrivateKeyWithClient(keys.clientPK, clientEthClient) + if err != nil { + t.Fatalf("client signer: %v", err) + } + realFacilitatorSigner, err := newRealFacilitatorEvmSigner(keys.facilitatorPK, keys.rpcURL) + if err != nil { + t.Fatalf("facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingReceiptSigner{FacilitatorEvmSigner: realFacilitatorSigner} + authorizerSigner, err := newBatchedAuthorizerSigner(keys.authorizerPK) + if err != nil { + t.Fatalf("authorizer signer: %v", err) + } + + salt := randomChannelSalt(t) + clientScheme := batchedclient.NewBatchSettlementEvmScheme(clientSigner, &batchedclient.BatchSettlementEvmSchemeOptions{ + DepositMultiplier: 5, + Salt: salt, + }) + x402Client := x402.Newx402Client() + x402Client.Register(batchedTestNetwork, clientScheme) + + facilitatorScheme := batchedfacilitator.NewBatchSettlementEvmScheme(facilitatorSigner, authorizerSigner) + x402Facilitator := x402.Newx402Facilitator() + x402Facilitator.Register([]x402.Network{batchedTestNetwork}, facilitatorScheme) + facClient := &localEvmFacilitatorClient{facilitator: x402Facilitator} + + serverScheme := batchedserver.NewBatchSettlementEvmScheme(keys.receiver, &batchedserver.BatchSettlementEvmSchemeServerConfig{ + ReceiverAuthorizerSigner: authorizerSigner, + }) + x402Server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(facClient)) + x402Server.Register(batchedTestNetwork, serverScheme) + if err := x402Server.Initialize(ctx); err != nil { + t.Fatalf("server initialize: %v", err) + } + + requirements := types.PaymentRequirements{ + Scheme: batchsettlement.SchemeBatched, + Network: string(batchedTestNetwork), + Asset: batchedTestUSDC, + Amount: "1000", + PayTo: keys.receiver, + MaxTimeoutSeconds: 3600, + Extra: map[string]interface{}{ + "name": "USDC", + "version": "2", + "assetTransferMethod": "eip3009", + "receiverAuthorizer": authorizerSigner.Address(), + }, + } + accepts := []types.PaymentRequirements{requirements} + resource := batchedResourceInfo() + + prr := x402Server.CreatePaymentRequiredResponse(accepts, resource, "", nil) + depositPayload, err := x402Client.CreatePaymentPayload(ctx, accepts[0], resource, prr.Extensions) + if err != nil { + t.Fatalf("createPaymentPayload: %v", err) + } + if pType, _ := depositPayload.Payload["type"].(string); pType != "deposit" { + t.Fatalf("expected payload type=deposit, got %v", depositPayload.Payload["type"]) + } + accepted := x402Server.FindMatchingRequirements(accepts, depositPayload) + if accepted == nil { + t.Fatal("no matching requirements") + } + + // Attempt 1: broadcast is real; the receipt wait is forced to fail + // regardless of real chain confirmation speed. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := x402Server.SettlePayment(ctx, depositPayload, *accepted, nil) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced receipt-wait failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != evmmech.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", evmmech.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction hash on the settlement_pending error") + } + firstTxHash := se.Transaction + + // Attempt 2: identical deposit payload, receipt-waiting no longer forced + // to fail. Must reconcile against firstTxHash (pending-store hit) rather + // than re-verifying and re-broadcasting a second deposit. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := x402Server.SettlePayment(ctx, depositPayload, *accepted, nil) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original deposit tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled deposit settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstTxHash { + t.Fatalf("Reconciliation must reuse the already-broadcast deposit transaction (no second broadcast): first=%s second=%s", + firstTxHash, settleResponse.Transaction) + } +} diff --git a/go/test/integration/evm_test.go b/go/test/integration/evm_test.go index 8ff6c29142..67c17417cb 100644 --- a/go/test/integration/evm_test.go +++ b/go/test/integration/evm_test.go @@ -6,10 +6,12 @@ package integration_test import ( "context" "crypto/ecdsa" + "errors" "fmt" "math/big" "os" "strings" + "sync/atomic" "testing" "time" @@ -2000,3 +2002,368 @@ func TestEVMIntegrationV2UptoPermit2(t *testing.T) { } }) } + +// forcedPendingReceiptSigner wraps a real FacilitatorEvmSigner and, while +// ForcePending is true, forces WaitForTransactionReceipt to observe a +// deadline so short it cannot possibly see the real confirmation, regardless +// of the ctx/network timing the surrounding call was given. Every other +// method (WriteContract, SendTransaction, GetCode, etc.) is delegated +// unmodified via interface embedding, so broadcasts are always real on-chain +// transactions. This gives deterministic, network-speed-independent coverage +// of the settlement_pending path without racing a real chain's block time. +type forcedPendingReceiptSigner struct { + evm.FacilitatorEvmSigner + forcePending atomic.Bool +} + +func (s *forcedPendingReceiptSigner) WaitForTransactionReceipt(ctx context.Context, txHash string) (*evm.TransactionReceipt, error) { + if s.forcePending.Load() { + forcedCtx, cancel := context.WithTimeout(ctx, time.Millisecond) + defer cancel() + return s.FacilitatorEvmSigner.WaitForTransactionReceipt(forcedCtx, txHash) + } + return s.FacilitatorEvmSigner.WaitForTransactionReceipt(ctx, txHash) +} + +// TestEVMIntegrationV2_SettlementPendingReconciliation exercises the +// settlement-pending-auto-recovery mechanism layer against a real on-chain +// EIP-3009 settlement: the first Settle call broadcasts for real but is +// forced (via forcedPendingReceiptSigner) to fail its receipt wait, producing +// a settlement_pending SettleError with the broadcast hash attached and a +// PendingSettlementStore entry populated. A second Settle call with the same +// payload, now with receipt-waiting un-forced, must hit the pending-store +// fast path (skip verify/broadcast) and reconcile against that +// already-broadcast transaction, returning success once it actually confirms +// on-chain — and, critically, with the SAME transaction hash as the first +// attempt, proving no second transaction was ever broadcast. +func TestEVMIntegrationV2_SettlementPendingReconciliation(t *testing.T) { + clientPrivateKey := os.Getenv("EVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("EVM_FACILITATOR_PRIVATE_KEY") + resourceServerAddress := os.Getenv("EVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || resourceServerAddress == "" { + t.Skip("Skipping settlement_pending reconciliation test: EVM_CLIENT_PRIVATE_KEY, EVM_FACILITATOR_PRIVATE_KEY, and EVM_RESOURCE_SERVER_ADDRESS must be set") + } + + clientSigner, err := newRealClientEvmSigner(clientPrivateKey) + if err != nil { + t.Fatalf("Failed to create client signer: %v", err) + } + client := x402.Newx402Client() + client.Register("eip155:84532", exactevmclient.NewExactEvmScheme(clientSigner, nil)) + + realFacilitatorSigner, err := newRealFacilitatorEvmSigner(facilitatorPrivateKey, "https://sepolia.base.org") + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingReceiptSigner{FacilitatorEvmSigner: realFacilitatorSigner} + evmFacilitatorScheme := exactevmfacilitator.NewExactEvmScheme(facilitatorSigner, &exactevmfacilitator.ExactEvmSchemeConfig{}) + + accepts := []types.PaymentRequirements{ + { + Scheme: evm.SchemeExact, + Network: "eip155:84532", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // USDC on Base Sepolia + Amount: "1000", + PayTo: resourceServerAddress, + Extra: map[string]interface{}{"name": "USDC", "version": "2"}, + }, + } + resource := &types.ResourceInfo{URL: "https://api.example.com/premium"} + + selected, err := client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("Failed to select payment requirements: %v", err) + } + paymentPayload, err := client.CreatePaymentPayload(context.Background(), selected, resource, nil) + if err != nil { + t.Fatalf("Failed to create payment payload: %v", err) + } + + // Attempt 1: broadcast is real; the receipt wait is forced to fail + // regardless of real chain confirmation speed. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := evmFacilitatorScheme.Settle(context.Background(), paymentPayload, accepts[0], nil) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced receipt-wait failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != evm.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", evm.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction hash on the settlement_pending error") + } + firstTxHash := se.Transaction + + // Attempt 2: identical payload/requirements, receipt-waiting no longer + // forced to fail. Must reconcile against firstTxHash (pending-store hit) + // rather than re-verifying and re-broadcasting. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := evmFacilitatorScheme.Settle(context.Background(), paymentPayload, accepts[0], nil) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstTxHash { + t.Fatalf("Reconciliation must reuse the already-broadcast transaction (no second broadcast): first=%s second=%s", + firstTxHash, settleResponse.Transaction) + } +} + +// TestEVMIntegrationV2_ResourceServerSettlementPendingRetry exercises the +// generic x402ResourceServer.SettlePayment single-retry-on-settlement_pending +// path (settleWithPendingRetry in server.go) against a real broadcast. While +// forcedPendingReceiptSigner.forcePending is true for the whole call, both +// the initial attempt and the SDK's automatic single retry are forced to +// fail their receipt wait, so both are expected to observe +// settlement_pending. The key assertion is that the retry's reported +// transaction hash is identical to the first attempt's, proving the +// resource-server retry drove the mechanism's pending-cache fast path +// (reconciling against the one broadcast transaction) rather than causing a +// second on-chain broadcast. +func TestEVMIntegrationV2_ResourceServerSettlementPendingRetry(t *testing.T) { + clientPrivateKey := os.Getenv("EVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("EVM_FACILITATOR_PRIVATE_KEY") + resourceServerAddress := os.Getenv("EVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || resourceServerAddress == "" { + t.Skip("Skipping resource-server settlement_pending retry test: EVM_CLIENT_PRIVATE_KEY, EVM_FACILITATOR_PRIVATE_KEY, and EVM_RESOURCE_SERVER_ADDRESS must be set") + } + + clientSigner, err := newRealClientEvmSigner(clientPrivateKey) + if err != nil { + t.Fatalf("Failed to create client signer: %v", err) + } + client := x402.Newx402Client() + client.Register("eip155:84532", exactevmclient.NewExactEvmScheme(clientSigner, nil)) + + realFacilitatorSigner, err := newRealFacilitatorEvmSigner(facilitatorPrivateKey, "https://sepolia.base.org") + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingReceiptSigner{FacilitatorEvmSigner: realFacilitatorSigner} + facilitator := x402.Newx402Facilitator() + facilitator.Register([]x402.Network{"eip155:84532"}, exactevmfacilitator.NewExactEvmScheme(facilitatorSigner, &exactevmfacilitator.ExactEvmSchemeConfig{})) + facilitatorClient := &localEvmFacilitatorClient{facilitator: facilitator} + + server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(facilitatorClient)) + server.Register("eip155:84532", exactevmserver.NewExactEvmScheme()) + if err := server.Initialize(context.Background()); err != nil { + t.Fatalf("Failed to initialize server: %v", err) + } + + accepts := []types.PaymentRequirements{ + { + Scheme: evm.SchemeExact, + Network: "eip155:84532", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "1000", + PayTo: resourceServerAddress, + Extra: map[string]interface{}{"name": "USDC", "version": "2"}, + }, + } + resource := &types.ResourceInfo{URL: "https://api.example.com/premium"} + + selected, err := client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("Failed to select payment requirements: %v", err) + } + paymentPayload, err := client.CreatePaymentPayload(context.Background(), selected, resource, nil) + if err != nil { + t.Fatalf("Failed to create payment payload: %v", err) + } + accepted := server.FindMatchingRequirements(accepts, paymentPayload) + if accepted == nil { + t.Fatal("No matching payment requirements found") + } + + facilitatorSigner.forcePending.Store(true) + + _, settleErr := server.SettlePayment(context.Background(), paymentPayload, *accepted, nil) + if settleErr == nil { + t.Fatal("Expected the resource server's (retried) settle to still return settlement_pending while receipt-waiting is forced to fail, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != evm.ErrSettlementPending { + t.Fatalf("Expected errorReason %q after the single automatic retry, got %q (%v)", evm.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction hash after the retried settlement_pending") + } + firstAttemptTxHash := se.Transaction + + // Reconcile with receipt-waiting no longer forced to fail, directly + // against the resource server (its facilitator client shares the same + // in-process mechanism/pending-store instance) to confirm exactly one + // transaction was ever broadcast across every attempt so far. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := server.SettlePayment(context.Background(), paymentPayload, *accepted, nil) + if settleErr != nil { + t.Fatalf("Expected final reconciliation to succeed once the original tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected final reconciled settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstAttemptTxHash { + t.Fatalf("Resource-server retry must not cause a second broadcast: first-attempt tx=%s final tx=%s", + firstAttemptTxHash, settleResponse.Transaction) + } +} + +// TestEVMIntegrationV2UptoPermit2_SettlementPendingReconciliation exercises +// the settlement-pending-auto-recovery mechanism layer against a real +// on-chain EVM upto Permit2 settlement, mirroring +// TestEVMIntegrationV2_SettlementPendingReconciliation's EIP-3009 coverage +// for the separate upto/Permit2 mechanism code path: the first settle +// broadcasts for real but is forced (via forcedPendingReceiptSigner) to fail +// its receipt wait, producing a settlement_pending SettleError with the +// broadcast hash attached. A second settle with the identical payload, now +// with receipt-waiting no longer forced to fail, must hit the pending-store +// fast path and reconcile against that already-broadcast transaction — with +// the SAME transaction hash as the first attempt, proving no second +// broadcast occurred. +func TestEVMIntegrationV2UptoPermit2_SettlementPendingReconciliation(t *testing.T) { + clientPrivateKey := os.Getenv("EVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("EVM_FACILITATOR_PRIVATE_KEY") + resourceServerAddress := os.Getenv("EVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || resourceServerAddress == "" { + t.Skip("Skipping EVM upto Permit2 settlement_pending reconciliation test: EVM_CLIENT_PRIVATE_KEY, EVM_FACILITATOR_PRIVATE_KEY, and EVM_RESOURCE_SERVER_ADDRESS must be set") + } + + ctx := context.Background() + rpcURL := "https://sepolia.base.org" + + waitForPendingTransactions(t, ctx, facilitatorPrivateKey, rpcURL) + revokePermit2Approval(t, ctx, clientPrivateKey, + "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + rpcURL, + ) + + clientEthClient, err := ethclient.Dial(rpcURL) + if err != nil { + t.Fatalf("Failed to connect to Base Sepolia: %v", err) + } + defer clientEthClient.Close() + clientSigner, err := evmsigners.NewClientSignerFromPrivateKeyWithClient(clientPrivateKey, clientEthClient) + if err != nil { + t.Fatalf("Failed to create client signer: %v", err) + } + client := x402.Newx402Client() + client.Register("eip155:84532", uptoevmclient.NewUptoEvmScheme(clientSigner, nil)) + + realFacilitatorSigner, err := newPermit2FacilitatorEvmSigner(ctx, facilitatorPrivateKey, rpcURL) + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingReceiptSigner{FacilitatorEvmSigner: realFacilitatorSigner} + uptoFacilitatorScheme := uptoevmfacilitator.NewUptoEvmScheme(facilitatorSigner, nil) + + server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(&localEvmFacilitatorClient{ + facilitator: func() *x402.X402Facilitator { + f := x402.Newx402Facilitator() + f.Register([]x402.Network{"eip155:84532"}, uptoFacilitatorScheme) + return f + }(), + })) + server.Register("eip155:84532", uptoevmserver.NewUptoEvmScheme()) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Failed to initialize server: %v", err) + } + + accepts, err := server.BuildPaymentRequirementsFromConfig(ctx, x402.ResourceConfig{ + Scheme: evm.SchemeUpto, + Network: "eip155:84532", + PayTo: resourceServerAddress, + Price: "$0.001", + MaxTimeoutSeconds: 300, + }) + if err != nil { + t.Fatalf("Failed to build payment requirements: %v", err) + } + resource := &types.ResourceInfo{URL: "https://api.example.com/upto-permit2-pending"} + serverExtensions := map[string]interface{}{ + "eip2612GasSponsoring": map[string]interface{}{ + "info": map[string]interface{}{"description": "EIP-2612 gas sponsoring", "version": "1"}, + "schema": map[string]interface{}{}, + }, + } + paymentRequiredResponse := server.CreatePaymentRequiredResponse(accepts, resource, "", serverExtensions) + + selected, err := client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("Failed to select payment requirements: %v", err) + } + paymentPayload, err := client.CreatePaymentPayload(ctx, selected, resource, paymentRequiredResponse.Extensions) + if err != nil { + t.Fatalf("Failed to create payment payload: %v", err) + } + accepted := server.FindMatchingRequirements(accepts, paymentPayload) + if accepted == nil { + t.Fatal("No matching payment requirements found") + } + + // Verify first, matching the standard client/server/facilitator flow: + // this also lets the client's Permit2 approval transaction (sent as + // part of CreatePaymentPayload right after the revoke above) land + // on-chain before settlement is attempted. + verifyResponse, err := uptoFacilitatorScheme.Verify(ctx, paymentPayload, *accepted, nil) + if err != nil { + t.Fatalf("Failed to verify payment: %v", err) + } + if !verifyResponse.IsValid { + t.Fatalf("Payment verification failed: %s", verifyResponse.InvalidReason) + } + + // Attempt 1: broadcast is real; the receipt wait is forced to fail + // regardless of real chain confirmation speed. Call the mechanism + // directly (bypassing the resource server's own single-retry) so this + // test isolates the mechanism-level pending-store fast path, matching + // TestEVMIntegrationV2_SettlementPendingReconciliation's approach. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := uptoFacilitatorScheme.Settle(ctx, paymentPayload, *accepted, nil) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced receipt-wait failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != evm.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", evm.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction hash on the settlement_pending error") + } + firstTxHash := se.Transaction + + // Attempt 2: identical payload/requirements, receipt-waiting no longer + // forced to fail. Must reconcile against firstTxHash (pending-store hit) + // rather than re-verifying and re-broadcasting. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := uptoFacilitatorScheme.Settle(ctx, paymentPayload, *accepted, nil) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstTxHash { + t.Fatalf("Reconciliation must reuse the already-broadcast transaction (no second broadcast): first=%s second=%s", + firstTxHash, settleResponse.Transaction) + } +} diff --git a/go/test/integration/svm_test.go b/go/test/integration/svm_test.go index 35781bd2a5..2584ce30cf 100644 --- a/go/test/integration/svm_test.go +++ b/go/test/integration/svm_test.go @@ -5,8 +5,10 @@ package integration_test import ( "context" + "errors" "fmt" "os" + "sync/atomic" "testing" "time" @@ -116,7 +118,7 @@ func (s *realFacilitatorSvmSigner) SimulateTransaction(ctx context.Context, tx * } opts := rpc.SimulateTransactionOpts{ - SigVerify: true, + SigVerify: false, ReplaceRecentBlockhash: false, Commitment: svm.DefaultCommitment, } @@ -197,12 +199,114 @@ func (s *realFacilitatorSvmSigner) ConfirmTransaction(ctx context.Context, signa } // Wait before retrying - time.Sleep(svm.ConfirmRetryDelay) + delay := svm.ConfirmRetryDelay + if attempt < svm.ConfirmInitialAttempts { + delay = svm.ConfirmInitialRetryDelay + } + time.Sleep(delay) } return fmt.Errorf("transaction confirmation timed out after %d attempts", svm.MaxConfirmAttempts) } +func (s *realFacilitatorSvmSigner) GetAccountInfo( + ctx context.Context, + account solana.PublicKey, + network string, + opts *rpc.GetAccountInfoOpts, +) (*rpc.GetAccountInfoResult, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return rpcClient.GetAccountInfoWithOpts(ctx, account, opts) +} + +func (s *realFacilitatorSvmSigner) GetLatestBlockhash(ctx context.Context, network string) (solana.Hash, uint64, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return solana.Hash{}, 0, err + } + latest, err := rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return solana.Hash{}, 0, err + } + return latest.Value.Blockhash, latest.Value.LastValidBlockHeight, nil +} + +func (s *realFacilitatorSvmSigner) GetSlot(ctx context.Context, network string, commitment rpc.CommitmentType) (uint64, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, err + } + return rpcClient.GetSlot(ctx, commitment) +} + +func (s *realFacilitatorSvmSigner) SimulateTransactionWithOpts( + ctx context.Context, + tx *solana.Transaction, + network string, + opts *rpc.SimulateTransactionOpts, +) error { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return err + } + result, err := rpcClient.SimulateTransactionWithOpts(ctx, tx, opts) + if err != nil { + return fmt.Errorf("simulation failed: %w", err) + } + if result != nil && result.Value != nil && result.Value.Err != nil { + return fmt.Errorf("simulation failed: transaction would fail on-chain") + } + return nil +} + +func (s *realFacilitatorSvmSigner) GetProgramAccounts( + ctx context.Context, + network string, + programID solana.PublicKey, + opts *rpc.GetProgramAccountsOpts, +) (rpc.GetProgramAccountsResult, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return rpcClient.GetProgramAccountsWithOpts(ctx, programID, opts) +} + +func (s *realFacilitatorSvmSigner) SimulateTransactionWithInnerInstructions(ctx context.Context, tx *solana.Transaction, network string) ([]rpc.InnerInstruction, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svm.SimulateWithInnerInstructions(ctx, rpcClient, tx) +} + +func (s *realFacilitatorSvmSigner) GetConfirmedTransactionInnerInstructions(ctx context.Context, signature solana.Signature, network string) ([]rpc.InnerInstruction, solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, nil, err + } + return svm.ConfirmedTransactionInnerInstructions(ctx, rpcClient, signature) +} + +func (s *realFacilitatorSvmSigner) GetTokenAccountBalance(ctx context.Context, tokenAccount solana.PublicKey, network string) (uint64, bool, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return 0, false, err + } + return svm.TokenAccountBalance(ctx, rpcClient, tokenAccount) +} + +func (s *realFacilitatorSvmSigner) FetchAddressLookupTables(ctx context.Context, tables []solana.PublicKey, network string) (map[solana.PublicKey]solana.PublicKeySlice, error) { + rpcClient, err := s.getRPC(ctx, network) + if err != nil { + return nil, err + } + return svm.AddressLookupTables(ctx, rpcClient, tables) +} + func (s *realFacilitatorSvmSigner) GetAddresses(ctx context.Context, network string) []solana.PublicKey { return []solana.PublicKey{s.privateKey.PublicKey()} } @@ -573,3 +677,213 @@ func TestSVMIntegrationV1(t *testing.T) { }) } */ + +// forcedPendingConfirmSigner wraps a real FacilitatorSvmSigner and, while +// forcePending is true, makes ConfirmTransaction fail immediately instead of +// delegating to the real (network-speed-dependent) confirmation polling. +// SignTransaction and SendTransaction are always delegated unmodified via +// interface embedding, so every broadcast is a real on-chain transaction; +// only the confirmation wait is deterministically forced to fail so the +// settlement_pending path can be exercised without racing devnet's real +// confirmation latency. +type forcedPendingConfirmSigner struct { + svm.FacilitatorSvmSigner + forcePending atomic.Bool +} + +func (s *forcedPendingConfirmSigner) ConfirmTransaction(ctx context.Context, signature solana.Signature, network string) error { + if s.forcePending.Load() { + return fmt.Errorf("forced confirmation failure for settlement_pending integration test") + } + return s.FacilitatorSvmSigner.ConfirmTransaction(ctx, signature, network) +} + +// buildSvmSettlementPendingFixture creates a real, signed, but not-yet-settled +// SVM exact payment payload/requirements pair shared by the settlement-pending +// integration tests below. +func buildSvmSettlementPendingFixture(t *testing.T, ctx context.Context, clientPrivateKey, facilitatorAddress, resourceServerAddress string) (types.PaymentPayload, types.PaymentRequirements) { + t.Helper() + + clientSigner, err := newRealClientSvmSigner(clientPrivateKey) + if err != nil { + t.Fatalf("Failed to create client signer: %v", err) + } + client := x402.Newx402Client() + client.Register(svm.SolanaDevnetCAIP2, svmclient.NewExactSvmScheme(clientSigner, &svm.ClientConfig{ + RPCURL: "https://api.devnet.solana.com", + })) + + accepts := []types.PaymentRequirements{ + { + Scheme: svm.SchemeExact, + Network: svm.SolanaDevnetCAIP2, + Asset: svm.USDCDevnetAddress, + Amount: "1000", + PayTo: resourceServerAddress, + Extra: map[string]interface{}{ + "feePayer": facilitatorAddress, + }, + }, + } + resource := &types.ResourceInfo{URL: "https://api.example.com/premium"} + + selected, err := client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("Failed to select payment requirements: %v", err) + } + paymentPayload, err := client.CreatePaymentPayload(ctx, selected, resource, nil) + if err != nil { + t.Fatalf("Failed to create payment payload: %v", err) + } + return paymentPayload, accepts[0] +} + +// TestSVMIntegrationV2_SettlementPendingReconciliation exercises the +// settlement-pending-auto-recovery mechanism layer against a real on-chain +// SVM exact settlement: the first Settle call broadcasts for real but is +// forced (via forcedPendingConfirmSigner) to fail ConfirmTransaction, +// producing a settlement_pending SettleError with the broadcast signature +// attached and a PendingSettlementStore entry populated. A second Settle +// call with the same payload, now with confirmation no longer forced to +// fail, must hit the pending-store fast path (skip verify/re-send) and +// reconcile against that already-broadcast transaction, returning success +// once it actually confirms on-chain — with the SAME signature as the first +// attempt, proving no second transaction was ever broadcast. +func TestSVMIntegrationV2_SettlementPendingReconciliation(t *testing.T) { + clientPrivateKey := os.Getenv("SVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("SVM_FACILITATOR_PRIVATE_KEY") + facilitatorAddress := os.Getenv("SVM_FACILITATOR_ADDRESS") + resourceServerAddress := os.Getenv("SVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || facilitatorAddress == "" || resourceServerAddress == "" { + t.Skip("Skipping SVM settlement_pending reconciliation test: SVM_CLIENT_PRIVATE_KEY, SVM_FACILITATOR_PRIVATE_KEY, SVM_FACILITATOR_ADDRESS, and SVM_RESOURCE_SERVER_ADDRESS must be set") + } + + ctx := context.Background() + + realFacilitatorSigner, err := newRealFacilitatorSvmSigner(facilitatorPrivateKey, "https://api.devnet.solana.com") + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingConfirmSigner{FacilitatorSvmSigner: realFacilitatorSigner} + svmFacilitatorScheme := svmfacilitator.NewExactSvmScheme(facilitatorSigner) + + paymentPayload, requirements := buildSvmSettlementPendingFixture(t, ctx, clientPrivateKey, facilitatorAddress, resourceServerAddress) + + // Attempt 1: broadcast is real; confirmation is forced to fail regardless + // of real devnet confirmation speed. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := svmFacilitatorScheme.Settle(ctx, paymentPayload, requirements, nil) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced confirmation failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != svmfacilitator.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", svmfacilitator.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction signature on the settlement_pending error") + } + firstSignature := se.Transaction + + // Attempt 2: identical payload/requirements, confirmation no longer + // forced to fail. Must reconcile against firstSignature (pending-store + // hit) rather than re-verifying and re-sending. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := svmFacilitatorScheme.Settle(ctx, paymentPayload, requirements, nil) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstSignature { + t.Fatalf("Reconciliation must reuse the already-broadcast transaction (no second broadcast): first=%s second=%s", + firstSignature, settleResponse.Transaction) + } +} + +// TestSVMIntegrationV2_ResourceServerSettlementPendingRetry exercises the +// generic x402ResourceServer.SettlePayment single-retry-on-settlement_pending +// path against a real SVM broadcast. While +// forcedPendingConfirmSigner.forcePending is true for the whole call, both +// the initial attempt and the SDK's automatic single retry are forced to +// fail confirmation, so both are expected to observe settlement_pending. The +// key assertion is that the retry's reported transaction signature is +// identical to the first attempt's, proving the resource-server retry drove +// the mechanism's pending-cache fast path (reconciling against the one +// broadcast transaction) rather than causing a second on-chain broadcast. +func TestSVMIntegrationV2_ResourceServerSettlementPendingRetry(t *testing.T) { + clientPrivateKey := os.Getenv("SVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("SVM_FACILITATOR_PRIVATE_KEY") + facilitatorAddress := os.Getenv("SVM_FACILITATOR_ADDRESS") + resourceServerAddress := os.Getenv("SVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || facilitatorAddress == "" || resourceServerAddress == "" { + t.Skip("Skipping SVM resource-server settlement_pending retry test: SVM_CLIENT_PRIVATE_KEY, SVM_FACILITATOR_PRIVATE_KEY, SVM_FACILITATOR_ADDRESS, and SVM_RESOURCE_SERVER_ADDRESS must be set") + } + + ctx := context.Background() + + realFacilitatorSigner, err := newRealFacilitatorSvmSigner(facilitatorPrivateKey, "https://api.devnet.solana.com") + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingConfirmSigner{FacilitatorSvmSigner: realFacilitatorSigner} + facilitator := x402.Newx402Facilitator() + facilitator.Register([]x402.Network{svm.SolanaDevnetCAIP2}, svmfacilitator.NewExactSvmScheme(facilitatorSigner)) + facilitatorClient := &localSvmFacilitatorClient{facilitator: facilitator, signer: realFacilitatorSigner} + + server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(facilitatorClient)) + server.Register(svm.SolanaDevnetCAIP2, svmserver.NewExactSvmScheme()) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Failed to initialize server: %v", err) + } + + paymentPayload, requirements := buildSvmSettlementPendingFixture(t, ctx, clientPrivateKey, facilitatorAddress, resourceServerAddress) + accepted := server.FindMatchingRequirements([]types.PaymentRequirements{requirements}, paymentPayload) + if accepted == nil { + t.Fatal("No matching payment requirements found") + } + + facilitatorSigner.forcePending.Store(true) + + _, settleErr := server.SettlePayment(ctx, paymentPayload, *accepted, nil) + if settleErr == nil { + t.Fatal("Expected the resource server's (retried) settle to still return settlement_pending while confirmation is forced to fail, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != svmfacilitator.ErrSettlementPending { + t.Fatalf("Expected errorReason %q after the single automatic retry, got %q (%v)", svmfacilitator.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction signature after the retried settlement_pending") + } + firstAttemptSignature := se.Transaction + + // Reconcile with confirmation no longer forced to fail, directly against + // the resource server (its facilitator client shares the same in-process + // mechanism/pending-store instance) to confirm exactly one transaction + // was ever broadcast across every attempt so far. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := server.SettlePayment(ctx, paymentPayload, *accepted, nil) + if settleErr != nil { + t.Fatalf("Expected final reconciliation to succeed once the original tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected final reconciled settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstAttemptSignature { + t.Fatalf("Resource-server retry must not cause a second broadcast: first-attempt tx=%s final tx=%s", + firstAttemptSignature, settleResponse.Transaction) + } +} diff --git a/go/test/integration/svm_upto_test.go b/go/test/integration/svm_upto_test.go index 65674fe66e..136b740c45 100644 --- a/go/test/integration/svm_upto_test.go +++ b/go/test/integration/svm_upto_test.go @@ -6,6 +6,7 @@ package integration_test import ( "context" + "errors" "os" "strconv" "strings" @@ -116,9 +117,7 @@ func newUptoSvmStack(t *testing.T, ctx context.Context) *uptoSvmStack { if err != nil { t.Fatalf("Failed to create facilitator signer: %v", err) } - uptoFacilitator := uptosvmfacilitator.NewUptoSvmScheme(facilitatorSigner, &uptosvmfacilitator.Config{ - RPCURL: uptoSvmRPCURL(), - }) + uptoFacilitator := uptosvmfacilitator.NewUptoSvmScheme(facilitatorSigner, nil) facilitator := x402.Newx402Facilitator() facilitator.Register([]x402.Network{svm.SolanaDevnetCAIP2}, uptoFacilitator) @@ -578,3 +577,244 @@ func TestSVMIntegrationV2Upto(t *testing.T) { } }) } + +// newUptoSvmStackWithForcedPendingSigner builds the same client/server/facilitator +// trio as newUptoSvmStack, except the facilitator's signer is wrapped in a +// forcedPendingConfirmSigner so ConfirmTransaction can be made to fail on +// demand (for settlement-pending-auto-recovery tests) while every other +// signer method — including broadcast — delegates to the real signer. +func newUptoSvmStackWithForcedPendingSigner(t *testing.T, ctx context.Context) (*uptoSvmStack, *forcedPendingConfirmSigner) { + t.Helper() + + clientPrivateKey := os.Getenv("SVM_CLIENT_PRIVATE_KEY") + facilitatorPrivateKey := os.Getenv("SVM_FACILITATOR_PRIVATE_KEY") + authorizerPrivateKey := os.Getenv("SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY") + resourceServerAddress := os.Getenv("SVM_RESOURCE_SERVER_ADDRESS") + + if clientPrivateKey == "" || facilitatorPrivateKey == "" || resourceServerAddress == "" { + t.Skip("Skipping SVM upto settlement_pending test: SVM_CLIENT_PRIVATE_KEY, " + + "SVM_FACILITATOR_PRIVATE_KEY, and SVM_RESOURCE_SERVER_ADDRESS must be set") + } + // The receiver authorizer only ever signs vouchers as raw Ed25519 messages + // (never a transaction, per its interface docs) — it needs no SOL/token + // balance, and every test built on this stack owns both the client and + // server sides of that signature (the server signs, the facilitator + // verifies), so any keypair is internally consistent even when it reaches + // the voucher/claim path. So unlike newUptoSvmStack, a missing env var + // here falls back to an ephemeral keypair instead of skipping. + if authorizerPrivateKey == "" { + authorizerPrivateKey = solana.NewWallet().PrivateKey.String() + } + + clientSigner, err := newRealClientSvmSigner(clientPrivateKey) + if err != nil { + t.Fatalf("Failed to create client signer: %v", err) + } + client := x402.Newx402Client() + // This test drives BuildPaymentRequirementsFromConfig/SelectPaymentRequirements + // directly rather than a full HTTP round trip, which otherwise trips the + // client's default allowed-assets spend control (see http_test.go / + // core_test.go for the same pattern). + client.DisableSpendControls() + client.Register(svm.SolanaDevnetCAIP2, uptosvmclient.NewUptoSvmScheme(clientSigner, &svm.ClientConfig{ + RPCURL: uptoSvmRPCURL(), + })) + + realFacilitatorSigner, err := newRealFacilitatorSvmSigner(facilitatorPrivateKey, uptoSvmRPCURL()) + if err != nil { + t.Fatalf("Failed to create facilitator signer: %v", err) + } + facilitatorSigner := &forcedPendingConfirmSigner{FacilitatorSvmSigner: realFacilitatorSigner} + uptoFacilitator := uptosvmfacilitator.NewUptoSvmScheme(facilitatorSigner, nil) + facilitator := x402.Newx402Facilitator() + facilitator.Register([]x402.Network{svm.SolanaDevnetCAIP2}, uptoFacilitator) + + authorizer, err := svmsigners.NewReceiverAuthorizerSignerFromPrivateKey(authorizerPrivateKey) + if err != nil { + t.Fatalf("Failed to create receiver authorizer signer: %v", err) + } + server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(&localSvmFacilitatorClient{ + facilitator: facilitator, + signer: realFacilitatorSigner, + })) + server.Register(svm.SolanaDevnetCAIP2, uptosvmserver.NewUptoSvmScheme(&uptosvmserver.Config{ + ReceiverAuthorizerSigner: authorizer, + RPCURL: uptoSvmRPCURL(), + })) + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Failed to initialize server: %v", err) + } + + return &uptoSvmStack{ + client: client, + server: server, + facilitator: uptoFacilitator, + payer: clientSigner.Address(), + authorizer: authorizer.Address(), + payTo: resourceServerAddress, + }, facilitatorSigner +} + +// TestSVMIntegrationV2Upto_DepositSettlementPendingReconciliation exercises +// the settlement-pending-auto-recovery mechanism layer against a real +// on-chain SVM upto channel-open (deposit): the first deposit settle +// broadcasts the open for real but is forced (via forcedPendingConfirmSigner) +// to fail ConfirmTransaction, producing a settlement_pending SettleError with +// the broadcast signature attached and a PendingSettlementStore entry +// populated (keyed on the channel id). A second settle with the identical +// payload, now with confirmation no longer forced to fail, must hit the +// pending-store fast path (skip re-validate/re-broadcast — re-opening the +// same channel PDA would otherwise hit ErrChannelAlreadyOpen) and reconcile +// against that already-broadcast open — succeeding once it actually +// confirms on-chain, with the SAME signature as the first attempt, proving +// the channel was only ever opened once. +func TestSVMIntegrationV2Upto_DepositSettlementPendingReconciliation(t *testing.T) { + ctx := context.Background() + stack, facilitatorSigner := newUptoSvmStackWithForcedPendingSigner(t, ctx) + + accepts, err := stack.server.BuildPaymentRequirementsFromConfig(ctx, x402.ResourceConfig{ + Scheme: svm.SchemeUpto, + Network: svm.SolanaDevnetCAIP2, + PayTo: stack.payTo, + Price: "$0.001", + MaxTimeoutSeconds: 300, + }) + if err != nil { + t.Fatalf("Failed to build payment requirements: %v", err) + } + resource := &types.ResourceInfo{ + URL: "https://api.example.com/upto-svm-pending", + Description: "Upto SVM settlement-pending test", + MimeType: "application/json", + } + paymentRequired := stack.server.CreatePaymentRequiredResponse(accepts, resource, "", nil) + + selected, err := stack.client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("Failed to select payment requirements: %v", err) + } + payload, err := retryWhileRateLimited(ctx, func() (types.PaymentPayload, error) { + return stack.client.CreatePaymentPayload(ctx, selected, resource, paymentRequired.Extensions) + }) + if err != nil { + t.Fatalf("Failed to create payment payload: %v", err) + } + accepted := stack.server.FindMatchingRequirements(accepts, payload) + if accepted == nil { + t.Fatal("No matching payment requirements found") + } + + // Attempt 1: the open broadcast is real; confirmation is forced to fail + // regardless of real devnet confirmation speed. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := retryWhileRateLimited(ctx, func() (*x402.SettleResponse, error) { + return stack.server.SettlePaymentWithExtensions(ctx, payload, *accepted, nil, nil, x402.SettlePhaseBeforeHandler) + }) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced confirmation failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != uptosvmfacilitator.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", uptosvmfacilitator.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction signature on the settlement_pending error") + } + firstSignature := se.Transaction + + // Attempt 2: identical deposit payload, confirmation no longer forced to + // fail. Must reconcile against firstSignature (pending-store hit) rather + // than re-validating and re-opening the channel. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := retryWhileRateLimited(ctx, func() (*x402.SettleResponse, error) { + return stack.server.SettlePaymentWithExtensions(ctx, payload, *accepted, nil, nil, x402.SettlePhaseBeforeHandler) + }) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original open tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled deposit settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstSignature { + t.Fatalf("Reconciliation must reuse the already-broadcast open transaction (channel can only be opened once): first=%s second=%s", + firstSignature, settleResponse.Transaction) + } +} + +// TestSVMIntegrationV2Upto_ClaimSettlementPendingReconciliation exercises the +// settlement-pending-auto-recovery mechanism layer against a real on-chain +// SVM upto claim (distribute): after a normal, successful deposit opens the +// channel, the claim settle broadcasts the voucher-authorized distribute for +// real but is forced (via forcedPendingConfirmSigner) to fail +// ConfirmTransaction, producing a settlement_pending SettleError with the +// broadcast signature attached and a PendingSettlementStore entry populated +// (keyed on the channel's settlement, distinct from the deposit's open key — +// see TestDepositCacheDoesNotBlockTheLaterClaim). A second settle with the +// identical payload, now with confirmation no longer forced to fail, must +// hit the pending-store fast path (skip re-validate/re-broadcast — signing +// and submitting a second distribute for an already-distributed channel +// would otherwise fail) and reconcile against that already-broadcast claim — +// succeeding once it actually confirms on-chain, with the SAME signature as +// the first attempt, proving the channel was only ever distributed once. +func TestSVMIntegrationV2Upto_ClaimSettlementPendingReconciliation(t *testing.T) { + ctx := context.Background() + stack, facilitatorSigner := newUptoSvmStackWithForcedPendingSigner(t, ctx) + + // Deposit normally (confirmation not forced to fail) so the channel is + // genuinely open before the claim step under test. + payload, requirements, before := stack.openChannel(t, ctx, "$0.001") + + // Attempt 1: the distribute broadcast is real; confirmation is forced to + // fail regardless of real devnet confirmation speed. + facilitatorSigner.forcePending.Store(true) + + _, settleErr := retryWhileRateLimited(ctx, func() (*x402.SettleResponse, error) { + return stack.server.SettlePaymentWithExtensions(ctx, payload, requirements, nil, nil, x402.SettlePhaseAfterHandler) + }) + if settleErr == nil { + t.Fatal("Expected settlement_pending error from a deliberately forced confirmation failure, got nil error") + } + var se *x402.SettleError + if !errors.As(settleErr, &se) { + t.Fatalf("Expected a *x402.SettleError, got %T: %v", settleErr, settleErr) + } + if se.ErrorReason != uptosvmfacilitator.ErrSettlementPending { + t.Fatalf("Expected errorReason %q, got %q (%v)", uptosvmfacilitator.ErrSettlementPending, se.ErrorReason, se) + } + if se.Transaction == "" { + t.Fatal("Expected a broadcast transaction signature on the settlement_pending error") + } + firstSignature := se.Transaction + + // Attempt 2: identical claim payload, confirmation no longer forced to + // fail. Must reconcile against firstSignature (pending-store hit) rather + // than re-signing and re-submitting the distribute. + facilitatorSigner.forcePending.Store(false) + + settleResponse, settleErr := retryWhileRateLimited(ctx, func() (*x402.SettleResponse, error) { + return stack.server.SettlePaymentWithExtensions(ctx, payload, requirements, nil, nil, x402.SettlePhaseAfterHandler) + }) + if settleErr != nil { + t.Fatalf("Expected the reconciliation settle to succeed once the original distribute tx confirms, got error: %v", settleErr) + } + if !settleResponse.Success { + t.Fatalf("Expected reconciled claim settlement to succeed, got: %+v", settleResponse) + } + if settleResponse.Transaction != firstSignature { + t.Fatalf("Reconciliation must reuse the already-broadcast distribute transaction (channel can only be distributed once): first=%s second=%s", + firstSignature, settleResponse.Transaction) + } + if channel := fetchChannel(t, ctx, payload); channel.Status != paymentchannels.StatusDistributed { + t.Errorf("Expected a distributed channel, got %s", channel.Status) + } + charged, err := strconv.ParseUint(requirements.Amount, 10, 64) + if err != nil { + t.Fatalf("Failed to parse the authorized amount: %v", err) + } + stack.assertSettled(t, ctx, requirements, before, charged) +} diff --git a/python/x402/CHANGELOG.md b/python/x402/CHANGELOG.md index 77b5f5db8a..0cfd8d2442 100644 --- a/python/x402/CHANGELOG.md +++ b/python/x402/CHANGELOG.md @@ -2,6 +2,24 @@ +## [2.21.0] - 2026-08-27 + +### Fixed + +- Restore Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) USDC as default assets so v1-legacy network names keep working after the shared CAIP-2 table migration. ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +- Stop persisting untrusted batch-settlement `channelState` from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped `chargedAmount` and any client-signed deposit, except when a present extra `chargedCumulativeAmount` does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. ([#3251](https://github.com/x402-foundation/x402/pull/3251)) - Thanks [@phdargen](https://github.com/phdargen)! + +### Added + +- Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin, and correct the legacy Sei testnet chain ID. ([#3227](https://github.com/x402-foundation/x402/pull/3227)) - Thanks [@alexander-sei](https://github.com/alexander-sei)! +- Add payment-flow handlers so schemes can declare authorization, upfront, and escrow verify/settle ordering, with matching 402 extra, client selection, and HTTP/MCP settlement receipts. ([#3247](https://github.com/x402-foundation/x402/pull/3247)) - Thanks [@phdargen](https://github.com/phdargen)! +- Added upfront payment flow support for the exact scheme on EVM, SVM, and TVM. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! + +### Misc + +- SVM `get_network_config` and `NETWORK_CONFIGS` now hold transport endpoints only (`rpc_url`, `ws_url`). Default assets stay in `default_assets.py`. EVM bundled network config remains removed; use `get_default_asset` and `get_evm_chain_id`. ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! + + ## [2.20.0] - 2026-08-18 ### Fixed diff --git a/python/x402/__init__.py b/python/x402/__init__.py index e035203c60..efcf7814ca 100644 --- a/python/x402/__init__.py +++ b/python/x402/__init__.py @@ -88,6 +88,14 @@ resolve_payment_flow_phases, ) +# Pending-settlement store (settlement_pending auto-recovery) +from .pending_settlement_store import ( + ERR_SETTLEMENT_PENDING, + PENDING_SETTLEMENT_TTL_SECONDS, + InMemoryPendingSettlementStore, + PendingSettlementStore, +) + # Types (re-export commonly used types) from .schemas import ( # Base @@ -163,7 +171,7 @@ x402ResourceServerSync, ) -__version__ = "2.20.0" +__version__ = "2.21.0" __all__ = [ # Version @@ -185,6 +193,10 @@ # Protocols "FacilitatorClient", "FacilitatorClientSync", + "PendingSettlementStore", + "InMemoryPendingSettlementStore", + "ERR_SETTLEMENT_PENDING", + "PENDING_SETTLEMENT_TTL_SECONDS", # Policies "default_payment_selector", "prefer_network", diff --git a/python/x402/changelog.d/3233.feature.md b/python/x402/changelog.d/3233.feature.md new file mode 100644 index 0000000000..f4d090b4f6 --- /dev/null +++ b/python/x402/changelog.d/3233.feature.md @@ -0,0 +1 @@ +Allow facilitators to configure the EVM transaction gas limit on `FacilitatorWeb3Signer`. diff --git a/python/x402/changelog.d/3241.bugfix.md b/python/x402/changelog.d/3241.bugfix.md deleted file mode 100644 index e9a37e6eb1..0000000000 --- a/python/x402/changelog.d/3241.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Restore Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) USDC as default assets so v1-legacy network names keep working after the shared CAIP-2 table migration. diff --git a/python/x402/changelog.d/add-sei-default-stablecoin.feature.md b/python/x402/changelog.d/add-sei-default-stablecoin.feature.md deleted file mode 100644 index 9e1b004c9e..0000000000 --- a/python/x402/changelog.d/add-sei-default-stablecoin.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin, and correct the legacy Sei testnet chain ID. diff --git a/python/x402/changelog.d/batch-settlement-local-truth.bugfix.md b/python/x402/changelog.d/batch-settlement-local-truth.bugfix.md deleted file mode 100644 index 336d93ab87..0000000000 --- a/python/x402/changelog.d/batch-settlement-local-truth.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Stop persisting untrusted batch-settlement `channelState` from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped `chargedAmount` and any client-signed deposit, except when a present extra `chargedCumulativeAmount` does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. diff --git a/python/x402/changelog.d/extension-responses-sidechannel.feature.md b/python/x402/changelog.d/extension-responses-sidechannel.feature.md new file mode 100644 index 0000000000..c930591153 --- /dev/null +++ b/python/x402/changelog.d/extension-responses-sidechannel.feature.md @@ -0,0 +1 @@ +Added optional `extension_responses` on `VerifyResponse` and `SettleResponse`. The HTTP facilitator client populates it from the `EXTENSION-RESPONSES` header. `encode_payment_response_header` excludes the sidechannel from buyer-facing `PAYMENT-RESPONSE` encoding. diff --git a/python/x402/changelog.d/fix-e2e-warnings.bugfix.md b/python/x402/changelog.d/fix-e2e-warnings.bugfix.md new file mode 100644 index 0000000000..b2e5cb70cd --- /dev/null +++ b/python/x402/changelog.d/fix-e2e-warnings.bugfix.md @@ -0,0 +1 @@ +Stop false-positive bazaar startup warnings for pre-enrichment HTTP extensions by injecting a synthetic method from the route pattern (or body/query inference) before JSON-schema validation. diff --git a/python/x402/changelog.d/payment-flow-handlers.feature.md b/python/x402/changelog.d/payment-flow-handlers.feature.md deleted file mode 100644 index 80295e2a22..0000000000 --- a/python/x402/changelog.d/payment-flow-handlers.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add payment-flow handlers so schemes can declare authorization, upfront, and escrow verify/settle ordering, with matching 402 extra, client selection, and HTTP/MCP settlement receipts. diff --git a/python/x402/changelog.d/remove-network-config-bundled-helpers.misc.md b/python/x402/changelog.d/remove-network-config-bundled-helpers.misc.md deleted file mode 100644 index f6ae025602..0000000000 --- a/python/x402/changelog.d/remove-network-config-bundled-helpers.misc.md +++ /dev/null @@ -1 +0,0 @@ -SVM `get_network_config` and `NETWORK_CONFIGS` now hold transport endpoints only (`rpc_url`, `ws_url`). Default assets stay in `default_assets.py`. EVM bundled network config remains removed; use `get_default_asset` and `get_evm_chain_id`. diff --git a/python/x402/http/extension_responses.py b/python/x402/http/extension_responses.py new file mode 100644 index 0000000000..41dd043e85 --- /dev/null +++ b/python/x402/http/extension_responses.py @@ -0,0 +1,50 @@ +"""Utilities for facilitator EXTENSION-RESPONSES sidechannel on HTTP.""" + +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any + +EXTENSION_RESPONSES_HEADER = "EXTENSION-RESPONSES" + +_logger = logging.getLogger("x402") + + +def extract_extension_responses_header(http_response: Any) -> dict[str, Any] | None: + """Decode the EXTENSION-RESPONSES header into a keyed extension map. + + Silently returns None when the header is absent or malformed. + + Args: + http_response: HTTP response object with a ``headers`` mapping. + + Returns: + Decoded extension responses keyed by extension name, or None. + """ + header = http_response.headers.get(EXTENSION_RESPONSES_HEADER) or http_response.headers.get( + "extension-responses" + ) + if not header: + return None + + try: + decoded = base64.b64decode(header).decode("utf-8") + header_extensions = json.loads(decoded) + if not isinstance(header_extensions, dict): + return None + return header_extensions + except Exception: + return None + + +def log_extension_responses(decoded: dict[str, Any] | None) -> None: + """Log decoded extension responses from the facilitator sidechannel.""" + if not decoded: + return + + _logger.info( + "[x402] extension responses: %s", + json.dumps(decoded), + ) diff --git a/python/x402/http/facilitator_client.py b/python/x402/http/facilitator_client.py index af4b16d6b8..1cce4b9a57 100644 --- a/python/x402/http/facilitator_client.py +++ b/python/x402/http/facilitator_client.py @@ -6,9 +6,7 @@ from __future__ import annotations -import base64 import json -import logging from typing import TYPE_CHECKING, Any, TypeVar from pydantic import ValidationError @@ -21,6 +19,7 @@ VerifyResponse, ) from ..schemas.v1 import PaymentPayloadV1, PaymentRequirementsV1 +from .extension_responses import extract_extension_responses_header, log_extension_responses from .facilitator_client_base import ( AuthHeaders, AuthProvider, @@ -54,6 +53,7 @@ SettleResponse, SupportedResponse, ) +_ResponseWithSidechannelT = TypeVar("_ResponseWithSidechannelT", VerifyResponse, SettleResponse) def _response_excerpt(response: Any, limit: int = 200) -> str: @@ -89,43 +89,14 @@ def _parse_facilitator_response( ) from exc -_logger = logging.getLogger("x402") - -_EXTENSION_RESPONSE_LOG_FIELD_ALLOWLIST = ["status", "rejectedReason", "reason", "code"] - - -def _log_extension_responses_header(response: Any) -> None: - """Read the EXTENSION-RESPONSES header and log allowlisted fields. - - Silently ignores malformed headers. - - Args: - response: The HTTP response object (httpx.Response). - """ - header = response.headers.get("EXTENSION-RESPONSES") or response.headers.get( - "extension-responses" - ) - if not header: - return - try: - decoded = base64.b64decode(header).decode("utf-8") - header_extensions: dict[str, Any] = json.loads(decoded) - if not isinstance(header_extensions, dict): - return - sanitized: dict[str, dict[str, Any]] = {} - for extension_key, payload in header_extensions.items(): - filtered: dict[str, Any] = {} - if isinstance(payload, dict): - for field in _EXTENSION_RESPONSE_LOG_FIELD_ALLOWLIST: - if field in payload: - filtered[field] = payload[field] - sanitized[extension_key] = filtered - _logger.info( - "[x402] extension responses: %s", - json.dumps(sanitized), - ) - except Exception: - pass +def _attach_extension_responses( + result: _ResponseWithSidechannelT, + response: Any, +) -> _ResponseWithSidechannelT: + """Populate extension_responses from the HTTP sidechannel.""" + header_obj = extract_extension_responses_header(response) + log_extension_responses(header_obj) + return result.model_copy(update={"extension_responses": header_obj}) # ============================================================================ @@ -330,8 +301,7 @@ async def _verify_http( raise ValueError(f"Facilitator verify failed ({response.status_code}): {response.text}") result = _parse_facilitator_response(response, VerifyResponse, "verify") - _log_extension_responses_header(response) - return result + return _attach_extension_responses(result, response) async def _settle_http( self, @@ -353,8 +323,7 @@ async def _settle_http( raise ValueError(f"Facilitator settle failed ({response.status_code}): {response.text}") result = _parse_facilitator_response(response, SettleResponse, "settle") - _log_extension_responses_header(response) - return result + return _attach_extension_responses(result, response) # ============================================================================ @@ -550,8 +519,7 @@ def _verify_http( raise ValueError(f"Facilitator verify failed ({response.status_code}): {response.text}") result = _parse_facilitator_response(response, VerifyResponse, "verify") - _log_extension_responses_header(response) - return result + return _attach_extension_responses(result, response) def _settle_http( self, @@ -573,5 +541,4 @@ def _settle_http( raise ValueError(f"Facilitator settle failed ({response.status_code}): {response.text}") result = _parse_facilitator_response(response, SettleResponse, "settle") - _log_extension_responses_header(response) - return result + return _attach_extension_responses(result, response) diff --git a/python/x402/http/middleware/_bazaar_utils.py b/python/x402/http/middleware/_bazaar_utils.py index 4bfdb95c34..7eaaa53142 100644 --- a/python/x402/http/middleware/_bazaar_utils.py +++ b/python/x402/http/middleware/_bazaar_utils.py @@ -6,11 +6,40 @@ from __future__ import annotations +import re import warnings from typing import Any from ..types import RouteConfig, RoutesConfig +_HTTP_VERB_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE|HEAD)\b", re.IGNORECASE) + + +def _with_synthetic_method(ext: dict[str, Any], pattern: str) -> dict[str, Any]: + """Inject a synthetic method for startup schema validation only. + + Pre-enrichment HTTP extensions intentionally omit ``method``; it is added at + request time by ``bazaar_resource_server_extension``. Without a synthetic + value, jsonschema reports a false positive for the required ``method`` field. + + Priority: (1) route pattern verb (e.g. ``"GET /api"``), (2) body vs query inference. + Returns the same object unchanged if ``method`` is already present. + """ + info = ext.get("info") or {} + input_data = info.get("input") or {} + if not isinstance(input_data, dict) or input_data.get("method"): + return ext + + verb_match = _HTTP_VERB_RE.match(pattern) + if verb_match: + method = verb_match.group(1).upper() + elif input_data.get("body") is not None or input_data.get("bodyType") is not None: + method = "POST" + else: + method = "GET" + + return {**ext, "info": {**info, "input": {**input_data, "method": method}}} + def check_if_bazaar_needed(routes: RoutesConfig) -> bool: """Check if any routes in the configuration declare bazaar extensions. @@ -114,7 +143,7 @@ def validate_bazaar_extensions(routes: RoutesConfig) -> None: stacklevel=3, ) continue - result = validate_discovery_extension(bazaar_ext) + result = validate_discovery_extension(_with_synthetic_method(bazaar_ext, pattern)) if not result.valid: warnings.warn( f'x402: Route "{pattern}" has an invalid bazaar extension: ' diff --git a/python/x402/http/utils.py b/python/x402/http/utils.py index 00d5105bfa..a11e7a7f93 100644 --- a/python/x402/http/utils.py +++ b/python/x402/http/utils.py @@ -67,7 +67,8 @@ def decode_payment_required_header( def encode_payment_response_header(settle_response: SettleResponse) -> str: """Encode a SettleResponse object as a base64 header value.""" - return safe_base64_encode(settle_response.model_dump_json(by_alias=True, exclude_none=True)) + payload = settle_response.model_dump(by_alias=True, exclude_none=True) + return safe_base64_encode(json.dumps(payload)) def decode_payment_response_header(header_value: str) -> SettleResponse: diff --git a/python/x402/mechanisms/evm/batch_settlement/facilitator/deposit.py b/python/x402/mechanisms/evm/batch_settlement/facilitator/deposit.py index 3f4c91c6ac..62b8abd2e7 100644 --- a/python/x402/mechanisms/evm/batch_settlement/facilitator/deposit.py +++ b/python/x402/mechanisms/evm/batch_settlement/facilitator/deposit.py @@ -14,6 +14,7 @@ ) from e from .....interfaces import FacilitatorContext +from .....pending_settlement_store import PendingSettlementStore from .....schemas import ( PaymentPayload, PaymentRequirements, @@ -23,7 +24,7 @@ from ...constants import ERR_SETTLEMENT_PENDING, TX_STATUS_SUCCESS from ...erc6492 import has_deployment_info, parse_erc6492_signature from ...multicall import MulticallCall, multicall -from ...settle_receipt import wait_for_receipt_and_build_response +from ...settle_receipt import ReceiptWaiter, wait_for_receipt_and_build_response from ...signer import FacilitatorEvmSigner from ...types import ERC6492SignatureData, TransactionReceipt from ...utils import ( @@ -184,6 +185,122 @@ def verify_deposit( ) +def _deposit_settlement_cache_key( + payload: DepositPayload, requirements: PaymentRequirements +) -> str: + """Return the unique-per-payload key used to key the PendingSettlementStore. + + Uses the authorization signature for the transfer method resolved from `requirements` + (matching Go/TS), not just whichever authorization field happens to be present, so a + payload carrying both shapes keys on the one requirements actually selected. Returns "" + when that authorization is absent (malformed payload), disabling the pending-settlement + fast path — the normal broadcast path still runs and surfaces the validation error. + """ + assert payload.deposit is not None + auth = payload.deposit.authorization + if _resolve_deposit_transfer_method(payload, requirements) == "permit2": + return auth.permit2_authorization.signature if auth.permit2_authorization else "" + return auth.erc3009_authorization.signature if auth.erc3009_authorization else "" + + +def _resolve_deposit_receipt_wait_signer( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: DepositPayload, + requirements: PaymentRequirements, + context: FacilitatorContext | None, +) -> ReceiptWaiter: + """Return the signer that should await the deposit's settlement receipt. + + The ERC-20-approval-gas-sponsoring extension's signer when that extension would + provide the transaction (it may broadcast via a different account), otherwise the + facilitator's own signer. Only called on a pending-settlement store hit, since the + normal (cache-miss) path already resolves this via `_resolve_deposit_execution`. + """ + if _resolve_deposit_transfer_method(payload, requirements) != "permit2": + return signer + branch = resolve_permit2_deposit_branch(signer, payment, payload, requirements, context) + if isinstance(branch, VerifyResponse) or branch.kind != "erc20Approval": + return signer + return branch.extension_signer if branch.extension_signer is not None else signer + + +def _reconcile_pending_deposit( + receipt_waiter: ReceiptWaiter, + signer: FacilitatorEvmSigner, + payload: DepositPayload, + network: str, + tx_hash: str, + pending_store: PendingSettlementStore, + cache_key: str, +) -> SettleResponse: + """Handle a PendingSettlementStore cache hit for settle_deposit. + + A prior call already broadcast tx_hash (its nonce/signature is now consumed onchain, + so re-broadcasting is not an option) but couldn't confirm it before returning + settlement_pending. Unlike the normal path, there is no reliable pre-broadcast + channel-state snapshot available here (it lived in the earlier, now-returned call), so + this skips the optimistic balance-add fallback entirely: on confirmation it reports + whatever the RPC currently reads (a short poll only guards against read-after-write + lag), and on failure to confirm it re-records the pending entry and returns another + settlement_pending for the caller to retry again later. + + Known limitation: the cache-miss path's unconfirmed-bundle-hash check (guarding against + a non-conforming ERC-20-approval extension signer that bundles a single hash covering + only approve(), never running deposit()) has no equivalent here, since it needs the + pre-broadcast channel state this path doesn't have — a receipt success here is trusted + at face value. Only affects a non-conforming extension signer combined with a + confirm-timeout on the original request. + """ + assert payload.channel_config is not None and payload.voucher is not None + assert payload.deposit is not None + config = payload.channel_config + payer = config.payer + voucher = payload.voucher + deposit = payload.deposit + + def _build_reconciled_success(_receipt: TransactionReceipt) -> SettleResponse: + extra: dict[str, Any] | None = None + deadline = time.time() + CHANNEL_STATE_POLL_S + while True: + try: + state = read_channel_state(signer, voucher.channel_id) + extra = { + "channelState": { + "channelId": voucher.channel_id, + "balance": str(state.balance), + "totalClaimed": str(state.total_claimed), + "withdrawRequestedAt": state.withdraw_requested_at, + "refundNonce": str(state.refund_nonce), + } + } + break + except Exception: + if time.time() >= deadline: + break + time.sleep(CHANNEL_STATE_POLL_INTERVAL_S) + + return SettleResponse( + success=True, + transaction=tx_hash, + network=network, + payer=payer, + amount=deposit.amount, + extra=extra, + ) + + return wait_for_receipt_and_build_response( + receipt_waiter, + tx_hash, + network, + payer, + failed_reason=ERR_DEPOSIT_TRANSACTION_FAILED, + on_success=_build_reconciled_success, + pending_store=pending_store, + pending_key=cache_key, + ) + + def settle_deposit( signer: FacilitatorEvmSigner, payment: PaymentPayload, @@ -192,8 +309,14 @@ def settle_deposit( context: FacilitatorContext | None = None, allowed_factories: list[str] | None = None, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: - """Verify then execute a deposit onchain via the appropriate collector.""" + """Verify then execute a deposit onchain via the appropriate collector. + + pending_store: Optional store letting a retried settle for the same deposit + authorization reconcile against an already-broadcast transaction instead of + re-verifying and re-broadcasting (see settlement_pending). + """ assert payload.channel_config is not None and payload.voucher is not None assert payload.deposit is not None network = str(requirements.network) @@ -202,6 +325,35 @@ def settle_deposit( deposit = payload.deposit voucher = payload.voucher + # Fast path: a prior settle for this exact authorization broadcast a transaction but + # couldn't confirm it in time. Reconcile against that transaction instead of + # re-broadcasting (which would revert on replay — the authorization's nonce/signature + # has already been consumed onchain). + cache_key = "" + if pending_store is not None: + cache_key = _deposit_settlement_cache_key(payload, requirements) + if cache_key: + cached_tx_hash = pending_store.get(cache_key) + if cached_tx_hash is not None: + # Remove before reconciling (rather than after) so a + # concurrent retry of the same payload misses here instead of + # also reconciling: it falls through to the normal broadcast + # path, which independently rejects it as an on-chain replay + # (nonce already consumed). + pending_store.delete(cache_key) + receipt_waiter = _resolve_deposit_receipt_wait_signer( + signer, payment, payload, requirements, context + ) + return _reconcile_pending_deposit( + receipt_waiter, + signer, + payload, + network, + cached_tx_hash, + pending_store, + cache_key, + ) + verified = verify_deposit(signer, payment, payload, requirements, context, allowed_factories) if not verified.is_valid: reason = verified.invalid_reason or ERR_INVALID_PAYLOAD_TYPE @@ -331,6 +483,28 @@ def _build_deposit_success(_receipt: TransactionReceipt) -> SettleResponse: network=network, payer=payer, ) + # The receipt itself succeeded (wait_for_receipt_and_build_response already + # cleared any pending entry before calling this on_success callback), but + # the deposit's effect on the channel balance couldn't be confirmed — re-mark + # pending so a retry reconciles against this same transaction. + if pending_store is not None and cache_key: + try: + pending_store.set(cache_key, tx) + except Exception as e: + # Can't guarantee a later retry will find this to reconcile + # against — a blind retry could re-verify/re-broadcast and + # double-send. Downgrade to terminal, preserving the + # transaction hash for manual reconciliation. + return SettleResponse( + success=False, + error_reason=ERR_DEPOSIT_TRANSACTION_FAILED, + error_message=( + f"settlement_pending, but failed to persist for retry: {e}" + ), + transaction=tx, + network=network, + payer=payer, + ) return SettleResponse( success=False, error_reason=ERR_SETTLEMENT_PENDING, @@ -360,6 +534,8 @@ def _build_deposit_success(_receipt: TransactionReceipt) -> SettleResponse: payer, failed_reason=ERR_DEPOSIT_TRANSACTION_FAILED, on_success=_build_deposit_success, + pending_store=pending_store, + pending_key=cache_key, ) except Exception as e: return SettleResponse( diff --git a/python/x402/mechanisms/evm/batch_settlement/facilitator/scheme.py b/python/x402/mechanisms/evm/batch_settlement/facilitator/scheme.py index f80da9b8cb..b1bd0cf337 100644 --- a/python/x402/mechanisms/evm/batch_settlement/facilitator/scheme.py +++ b/python/x402/mechanisms/evm/batch_settlement/facilitator/scheme.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from .....interfaces import FacilitatorContext +from .....pending_settlement_store import InMemoryPendingSettlementStore, PendingSettlementStore from .....schemas import ( PaymentPayload, PaymentRequirements, @@ -60,6 +61,7 @@ def __init__( signer: FacilitatorEvmSigner, authorizer_signer: AuthorizerSigner | None = None, config: BatchSettlementEvmFacilitatorConfig | None = None, + pending_store: PendingSettlementStore | None = None, ) -> None: """Create a facilitator scheme for verifying and settling batch-settlement payments. @@ -71,11 +73,20 @@ def __init__( missing authorizer signatures using this key. Omit it so no `receiverAuthorizer` is advertised and servers supply their own signatures. config: Optional configuration (e.g. ERC-6492 factory allowlist). + pending_store: Optional store letting a retried deposit settle reconcile against + an already-broadcast transaction instead of re-verifying and re-broadcasting + (see settlement_pending). Only the deposit settle path consults this store; + claim/settle/refund are single-signature onchain calls with no equivalent + broadcast-then-reconcile flow. Defaults to a fresh in-memory store when + omitted. """ self._signer = signer self._authorizer_signer = authorizer_signer cfg = config or BatchSettlementEvmFacilitatorConfig() self._eip6492_allowed_factories = list(cfg.eip6492_allowed_factories) + self._pending_store: PendingSettlementStore = ( + pending_store or InMemoryPendingSettlementStore() + ) def get_extra(self, network: str) -> dict | None: if self._authorizer_signer is None: @@ -153,6 +164,7 @@ def settle( context, self._eip6492_allowed_factories, data_suffix=data_suffix, + pending_store=self._pending_store, ) if is_claim_payload(raw): diff --git a/python/x402/mechanisms/evm/exact/facilitator.py b/python/x402/mechanisms/evm/exact/facilitator.py index e399bee1ff..39a3601acc 100644 --- a/python/x402/mechanisms/evm/exact/facilitator.py +++ b/python/x402/mechanisms/evm/exact/facilitator.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from typing import Any +from ....pending_settlement_store import InMemoryPendingSettlementStore, PendingSettlementStore from ....schemas import ( Network, PaymentPayload, @@ -36,6 +37,7 @@ from ..data_suffix import resolve_data_suffix from ..erc6492 import has_deployment_info, parse_erc6492_signature from ..exact.eip3009_utils import ( + ParsedEIP3009Authorization, classify_eip3009_signature, diagnose_eip3009_simulation_failure, execute_transfer_with_authorization, @@ -99,15 +101,23 @@ def __init__( self, signer: FacilitatorEvmSigner, config: ExactEvmSchemeConfig | None = None, + pending_store: PendingSettlementStore | None = None, ): """Create ExactEvmScheme facilitator. Args: signer: EVM signer for verification and settlement. config: Optional configuration. + pending_store: Optional store letting a retried settle for the same payload + reconcile against an already-broadcast transaction instead of + re-verifying and re-broadcasting (see settlement_pending). Defaults to a + fresh in-memory store when omitted. """ self._signer = signer self._config = config or ExactEvmSchemeConfig() + self._pending_store: PendingSettlementStore = ( + pending_store or InMemoryPendingSettlementStore() + ) def get_extra(self, network: Network) -> dict[str, Any] | None: """Get mechanism-specific extra data. EVM: None. @@ -362,7 +372,30 @@ def settle( SettleResponse with success, transaction, and payer. """ if is_permit2_payload(payload.payload): - return settle_permit2(self._signer, payload, requirements, context) + return settle_permit2( + self._signer, payload, requirements, context, pending_store=self._pending_store + ) + + network = str(requirements.network) + + # Fast path: a prior settle attempt for this exact payload already broadcast a + # transaction whose receipt wait failed (settlement_pending). The resource server's + # single automatic retry resends the identical payload, so check the + # pending-settlement store before re-verifying/re-broadcasting — reconcile against + # the already-broadcast transaction instead of creating a second one. + fast_path_payload = ExactEIP3009Payload.from_dict(payload.payload) + if fast_path_payload.signature: + cached_tx_hash = self._pending_store.get(fast_path_payload.signature) + if cached_tx_hash is not None: + # Remove before reconciling (rather than after) so a concurrent + # retry of the same payload misses here instead of also + # reconciling: it falls through to the normal broadcast path, + # which independently rejects it as an on-chain replay (nonce + # already consumed). + self._pending_store.delete(fast_path_payload.signature) + return self._reconcile_pending_eip3009( + fast_path_payload, requirements, network, cached_tx_hash + ) # First verify verify_result = self._verify( @@ -381,7 +414,6 @@ def settle( evm_payload = ExactEIP3009Payload.from_dict(payload.payload) payer = evm_payload.authorization.from_address - network = str(requirements.network) token_address = normalize_address(requirements.asset) try: @@ -446,30 +478,13 @@ def settle( data_suffix=data_suffix, ) - def _validate_transfer(receipt: TransactionReceipt) -> SettleResponse | None: - if receipt.logs is not None and not verify_eip3009_transfer_event( - receipt.logs, - token_address, - from_address=parsed_authorization.from_address, - to=parsed_authorization.to, - value=parsed_authorization.value, - ): - return SettleResponse( - success=False, - error_reason=ERR_TRANSFER_EVENT_MISMATCH, - transaction=tx_hash, - network=network, - payer=payer, - ) - return None - - return wait_for_receipt_and_build_response( - self._signer, - tx_hash, + return self._await_eip3009_settlement( + evm_payload.signature, + token_address, + parsed_authorization, network, payer, - failed_reason=ERR_TRANSACTION_FAILED, - validate_receipt=_validate_transfer, + tx_hash, ) except Exception as e: @@ -488,6 +503,80 @@ def _validate_transfer(receipt: TransactionReceipt) -> SettleResponse | None: transaction="", ) + def _reconcile_pending_eip3009( + self, + evm_payload: ExactEIP3009Payload, + requirements: PaymentRequirements, + network: str, + tx_hash: str, + ) -> SettleResponse: + """Handle a pending-settlement store hit. + + Skips verify and broadcast entirely (the payer is taken directly from the payload, + exactly as the original attempt did) and awaits the previously broadcast transaction. + """ + token_address = normalize_address(requirements.asset) + payer = evm_payload.authorization.from_address + try: + parsed_authorization = parse_eip3009_authorization(evm_payload.authorization) + except Exception as e: + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_FAILED, + error_message=str(e), + network=network, + payer=payer, + transaction="", + ) + return self._await_eip3009_settlement( + evm_payload.signature, token_address, parsed_authorization, network, payer, tx_hash + ) + + def _await_eip3009_settlement( + self, + pending_key: str | None, + token_address: str, + parsed_authorization: ParsedEIP3009Authorization, + network: str, + payer: str, + tx_hash: str, + ) -> SettleResponse: + """Wait for the broadcast transaction's receipt and verify its Transfer event. + + Shared by both the normal broadcast path and the pending-settlement reconciliation + path above. On a receipt-wait failure, the broadcast hash is recorded in the + pending-settlement store, keyed by the EIP-3009 signature, so a subsequent settle + attempt for the same payload can reconcile against it instead of broadcasting again. + """ + + def _validate_transfer(receipt: TransactionReceipt) -> SettleResponse | None: + if receipt.logs is not None and not verify_eip3009_transfer_event( + receipt.logs, + token_address, + from_address=parsed_authorization.from_address, + to=parsed_authorization.to, + value=parsed_authorization.value, + ): + return SettleResponse( + success=False, + error_reason=ERR_TRANSFER_EVENT_MISMATCH, + transaction=tx_hash, + network=network, + payer=payer, + ) + return None + + return wait_for_receipt_and_build_response( + self._signer, + tx_hash, + network, + payer, + failed_reason=ERR_TRANSACTION_FAILED, + validate_receipt=_validate_transfer, + pending_store=self._pending_store, + pending_key=pending_key, + ) + def _deploy_smart_wallet(self, sig_data: ERC6492SignatureData) -> None: """Deploy ERC-4337 smart wallet via ERC-6492 factory. diff --git a/python/x402/mechanisms/evm/exact/permit2_utils.py b/python/x402/mechanisms/evm/exact/permit2_utils.py index 10dda8174a..1262f55d25 100644 --- a/python/x402/mechanisms/evm/exact/permit2_utils.py +++ b/python/x402/mechanisms/evm/exact/permit2_utils.py @@ -16,6 +16,7 @@ ) from e from ....interfaces import FacilitatorContext # noqa: E402 +from ....pending_settlement_store import PendingSettlementStore # noqa: E402 from ....schemas import ( # noqa: E402 PaymentPayload, PaymentRequirements, @@ -49,7 +50,7 @@ ) from ..data_suffix import resolve_data_suffix # noqa: E402 from ..erc6492 import parse_erc6492_signature # noqa: E402 -from ..settle_receipt import wait_for_receipt_and_build_response # noqa: E402 +from ..settle_receipt import ReceiptWaiter, wait_for_receipt_and_build_response # noqa: E402 from ..signer import ClientEvmSigner, FacilitatorEvmSigner # noqa: E402 from ..types import ( # noqa: E402 ExactPermit2Authorization, @@ -401,11 +402,87 @@ def _verify_permit2_allowance( ) +def resolve_permit2_receipt_wait_signer( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + context: FacilitatorContext | None, +) -> ReceiptWaiter: + """Return the signer that should wait for the settlement receipt. + + The ERC-20-approval-gas-sponsoring extension's signer when that extension provided the + transaction (it may have broadcast via a different account), otherwise the facilitator's + own signer. Depends only on payload.extensions/context, so it can be resolved before + verify runs (the pending-settlement fast path skips verify entirely). + """ + from ....extensions.erc20_approval_gas_sponsoring import ( + ERC20_APPROVAL_GAS_SPONSORING_KEY, + Erc20ApprovalFacilitatorExtension, + extract_erc20_approval_gas_sponsoring_info, + ) + + if context is None: + return signer + erc20_info = extract_erc20_approval_gas_sponsoring_info(payload) + if erc20_info is None: + return signer + ext = context.get_extension(ERC20_APPROVAL_GAS_SPONSORING_KEY) + if not isinstance(ext, Erc20ApprovalFacilitatorExtension): + return signer + extension_signer = ext.resolve_signer(str(payload.accepted.network)) + return extension_signer if extension_signer is not None else signer + + +def reconcile_pending_permit2( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + context: FacilitatorContext | None, + pending_store: PendingSettlementStore | None, + signature: str | None, + network: str, + payer: str, + failed_reason: str, + amount: str | None = None, +) -> SettleResponse | None: + """Reconciles a PendingSettlementStore cache hit for a Permit2 signature. + + Shared by the exact and upto Permit2 schemes' settle() fast paths: a prior settle + attempt for this exact payload already broadcast a transaction whose receipt wait + failed (settlement_pending), so this re-awaits that same transaction instead of + re-verifying and re-broadcasting. + + Returns None when there is nothing to reconcile (no store configured, no + signature, or no cache entry), telling the caller to fall through to full + verification. + """ + if pending_store is None or not signature: + return None + cached_tx_hash = pending_store.get(signature) + if cached_tx_hash is None: + return None + # Remove before reconciling (rather than after) so a concurrent retry of + # the same payload misses here instead of also reconciling: it falls + # through to the normal broadcast path, which independently rejects it + # as an on-chain replay (nonce already consumed). + pending_store.delete(signature) + receipt_wait_signer = resolve_permit2_receipt_wait_signer(signer, payload, context) + return wait_for_receipt_and_build_response( + receipt_wait_signer, + cached_tx_hash, + network, + payer, + failed_reason=failed_reason, + amount=amount, + pending_store=pending_store, + pending_key=signature, + ) + + def settle_permit2( signer: FacilitatorEvmSigner, payload: PaymentPayload, requirements: PaymentRequirements, context: FacilitatorContext | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle a Permit2 payment on-chain. @@ -419,6 +496,9 @@ def settle_permit2( payload: Verified payment payload. requirements: Payment requirements. context: Optional facilitator context for extension lookup. + pending_store: Optional store letting a retried settle for the same payload + reconcile against an already-broadcast transaction instead of re-verifying + and re-broadcasting (see settlement_pending). Returns: SettleResponse with success, transaction, and payer. @@ -434,6 +514,22 @@ def settle_permit2( payer = permit2_payload.permit2_authorization.from_address network = str(requirements.network) + # Fast path: a prior settle attempt for this exact payload already broadcast a + # transaction whose receipt wait failed (settlement_pending). Reconcile against it + # instead of re-verifying/re-broadcasting. + reconciled = reconcile_pending_permit2( + signer, + payload, + context, + pending_store, + permit2_payload.signature, + network, + payer, + ERR_TRANSACTION_FAILED, + ) + if reconciled is not None: + return reconciled + # Re-verify before settling verify_result = verify_permit2(signer, payload, requirements, context) if not verify_result.is_valid: @@ -452,7 +548,12 @@ def settle_permit2( eip2612_info = extract_eip2612_gas_sponsoring_info(payload) if eip2612_info is not None: return _settle_permit2_with_eip2612( - signer, payload, permit2_payload, eip2612_info, data_suffix=data_suffix + signer, + payload, + permit2_payload, + eip2612_info, + data_suffix=data_suffix, + pending_store=pending_store, ) # Branch: ERC-20 approval gas sponsoring (broadcast approval + settle) @@ -463,11 +564,18 @@ def settle_permit2( extension_signer = ext.resolve_signer(str(payload.accepted.network)) if extension_signer is not None: return _settle_permit2_with_erc20_approval( - extension_signer, payload, permit2_payload, erc20_info, data_suffix=data_suffix + extension_signer, + payload, + permit2_payload, + erc20_info, + data_suffix=data_suffix, + pending_store=pending_store, ) # Branch: standard settle (allowance already on-chain) - return _settle_permit2_direct(signer, payload, permit2_payload, data_suffix=data_suffix) + return _settle_permit2_direct( + signer, payload, permit2_payload, data_suffix=data_suffix, pending_store=pending_store + ) def _build_permit2_settle_args( @@ -501,6 +609,7 @@ def _settle_permit2_direct( payload: PaymentPayload, permit2_payload: ExactPermit2Payload, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Standard Permit2 settle — allowance is already on-chain.""" payer = permit2_payload.permit2_authorization.from_address @@ -523,7 +632,13 @@ def _settle_permit2_direct( ) return wait_for_receipt_and_build_response( - signer, tx_hash, network, payer, failed_reason=ERR_TRANSACTION_FAILED + signer, + tx_hash, + network, + payer, + failed_reason=ERR_TRANSACTION_FAILED, + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: @@ -536,6 +651,7 @@ def _settle_permit2_with_eip2612( permit2_payload: ExactPermit2Payload, eip2612_info: Any, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle via settleWithPermit — includes the EIP-2612 permit atomically.""" payer = permit2_payload.permit2_authorization.from_address @@ -577,7 +693,13 @@ def _settle_permit2_with_eip2612( ) return wait_for_receipt_and_build_response( - signer, tx_hash, network, payer, failed_reason=ERR_TRANSACTION_FAILED + signer, + tx_hash, + network, + payer, + failed_reason=ERR_TRANSACTION_FAILED, + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: @@ -590,6 +712,7 @@ def _settle_permit2_with_erc20_approval( permit2_payload: ExactPermit2Payload, erc20_info: Any, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle via extension signer's send_transactions (approval + settle).""" payer = permit2_payload.permit2_authorization.from_address @@ -627,6 +750,8 @@ def _settle_permit2_with_erc20_approval( network, payer, failed_reason=ERR_TRANSACTION_FAILED, + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: return _map_settle_error(e, network, payer) diff --git a/python/x402/mechanisms/evm/exact/server.py b/python/x402/mechanisms/evm/exact/server.py index a4e86cdfae..a98ac87bc3 100644 --- a/python/x402/mechanisms/evm/exact/server.py +++ b/python/x402/mechanisms/evm/exact/server.py @@ -29,8 +29,8 @@ class ExactEvmScheme: scheme = SCHEME_EXACT default_asset_transfer_method = "eip3009" payment_flows = { - "eip3009": {"supported": ("authorization",), "default": "authorization"}, - "permit2": {"supported": ("authorization",), "default": "authorization"}, + "eip3009": {"supported": ("authorization", "upfront"), "default": "authorization"}, + "permit2": {"supported": ("authorization", "upfront"), "default": "authorization"}, } def __init__(self): diff --git a/python/x402/mechanisms/evm/settle_receipt.py b/python/x402/mechanisms/evm/settle_receipt.py index 299a8e7437..dbc34cfa47 100644 --- a/python/x402/mechanisms/evm/settle_receipt.py +++ b/python/x402/mechanisms/evm/settle_receipt.py @@ -5,6 +5,7 @@ from collections.abc import Callable from typing import Protocol +from ...pending_settlement_store import PendingSettlementStore from ...schemas import SettleResponse from .constants import ERR_SETTLEMENT_PENDING, TX_STATUS_SUCCESS from .types import TransactionReceipt @@ -46,27 +47,73 @@ def wait_for_receipt_and_build_response( amount: str | None = None, validate_receipt: Callable[[TransactionReceipt], SettleResponse | None] | None = None, on_success: Callable[[TransactionReceipt], SettleResponse] | None = None, + pending_store: PendingSettlementStore | None = None, + pending_key: str | None = None, ) -> SettleResponse: """Wait for a broadcast receipt and build the settlement response. - A reverted receipt and an explicit validation failure are terminal. A receipt-wait - failure, or an error raised while processing a confirmed receipt, leaves the broadcast - onchain with an unknown effect and returns settlement_pending with the hash. - validate_receipt runs after a successful receipt (e.g. a Transfer event check): return a SettleResponse to fail settlement, or None to accept success. on_success, when set, builds the success response from the receipt. + + When pending_store and pending_key are both provided, they key a store that a subsequent + settle attempt for the same payload (typically the resource server's single automatic + retry) uses to reconcile against tx_hash instead of re-broadcasting. Only a + settlement_pending outcome (wait failure/timeout, or an exception while processing a + confirmed receipt) is recorded — it is the only outcome safe to retry against. Every + other outcome — success, an invalid hash, a reverted receipt, or an explicit + validate_receipt rejection — is terminal and clears the entry instead; a reverted + receipt still has a transaction hash but is not safe to reconcile against indefinitely, + so it must not be cached until TTL expiry. + + If persisting the pending entry itself fails, a later retry has no record to reconcile + against — blindly returning settlement_pending would let it re-verify/re-broadcast and + risk a double-send. That case is downgraded to failed_reason, preserving tx_hash for + manual reconciliation. A failure to clear the entry is swallowed instead: the settle + outcome is already correct and must not be masked by a storage hiccup, and a stale entry + merely lingers until TTL expiry. """ + + def _mark_pending() -> SettleResponse | None: + if pending_store is None or not pending_key: + return None + try: + pending_store.set(pending_key, tx_hash) + except Exception as e: + return SettleResponse( + success=False, + error_reason=failed_reason, + error_message=f"settlement_pending, but failed to persist for retry: {e}", + transaction=tx_hash, + network=network, + payer=payer, + ) + return None + + def _clear_pending() -> None: + if pending_store is not None and pending_key: + try: + pending_store.delete(pending_key) + except Exception: + pass # best-effort; a stale entry merely lingers until TTL expiry + if not is_valid_tx_hash(tx_hash): + _clear_pending() return invalid_broadcast_hash_response(tx_hash, failed_reason, network, payer) try: receipt = signer.wait_for_transaction_receipt(tx_hash) except Exception as e: - return _settlement_pending_response(tx_hash, network, payer, e) + override = _mark_pending() + return ( + override + if override is not None + else _settlement_pending_response(tx_hash, network, payer, e) + ) try: if receipt.status != TX_STATUS_SUCCESS: + _clear_pending() return SettleResponse( success=False, error_reason=failed_reason, @@ -78,8 +125,11 @@ def wait_for_receipt_and_build_response( if validate_receipt is not None: validation_failure = validate_receipt(receipt) if validation_failure is not None: + _clear_pending() return validation_failure + _clear_pending() + if on_success is not None: return on_success(receipt) @@ -91,7 +141,12 @@ def wait_for_receipt_and_build_response( amount=amount, ) except Exception as e: - return _settlement_pending_response(tx_hash, network, payer, e) + override = _mark_pending() + return ( + override + if override is not None + else _settlement_pending_response(tx_hash, network, payer, e) + ) def _settlement_pending_response( diff --git a/python/x402/mechanisms/evm/signers.py b/python/x402/mechanisms/evm/signers.py index 1b34f04f5f..7ffba86c93 100644 --- a/python/x402/mechanisms/evm/signers.py +++ b/python/x402/mechanisms/evm/signers.py @@ -37,6 +37,22 @@ # hardcoded bound; override it below a platform request deadline. _DEFAULT_CONFIRMATION_TIMEOUT_SECONDS = 120 + +def _hex_with_0x_prefix(value: Any) -> str: + """Return value.hex() guaranteed to carry the 0x prefix. + + Newer major versions of hexbytes/web3 changed HexBytes.hex() to match plain + bytes.hex() semantics (no 0x prefix), whereas the rest of this module (and every + other EVM mechanism module) always manually prefixes hex-encoded byte strings with + "0x". Broadcast tx hashes returned bare would fail is_valid_tx_hash's "0x" + 64 hex + check downstream and be misreported as a terminal invalid-hash failure instead of a + real broadcast — this defends against that regardless of the installed + hexbytes/web3 version. + """ + raw = str(value.hex()) + return raw if raw.startswith("0x") else "0x" + raw + + # ERC20 ABI for balance checks _ERC20_BALANCE_ABI = [ { @@ -275,6 +291,7 @@ def __init__( private_key: str, rpc_url: str, confirmation_timeout_seconds: float = _DEFAULT_CONFIRMATION_TIMEOUT_SECONDS, + gas_limit: int = _DEFAULT_TX_GAS_LIMIT, ) -> None: """Initialize signer with private key and RPC connection. @@ -284,6 +301,7 @@ def __init__( confirmation_timeout_seconds: Seconds to wait for a settlement receipt before raising. Set below your platform's request deadline so settle returns `settlement_pending` instead of the process being killed mid-wait. + gas_limit: Gas limit for transactions sent by the facilitator. """ # Normalize private key format @@ -293,6 +311,7 @@ def __init__( self._account = Account.from_key(private_key) self._w3 = Web3(Web3.HTTPProvider(rpc_url)) self._confirmation_timeout_seconds = confirmation_timeout_seconds + self._gas_limit = gas_limit # Add PoA middleware for testnets (Base, Polygon, etc.) self._w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) @@ -510,7 +529,7 @@ def write_contract( { "from": self._account.address, "nonce": self._reserve_nonce(), - "gas": _DEFAULT_TX_GAS_LIMIT, + "gas": self._gas_limit, "gasPrice": self._w3.eth.gas_price, } ) @@ -525,7 +544,7 @@ def write_contract( signed_tx = self._account.sign_transaction(tx) tx_hash = self._w3.eth.send_raw_transaction(signed_tx.raw_transaction) - return tx_hash.hex() + return _hex_with_0x_prefix(tx_hash) def send_transaction(self, to: str, data: bytes) -> str: """Send a raw transaction. @@ -542,14 +561,14 @@ def send_transaction(self, to: str, data: bytes) -> str: "to": Web3.to_checksum_address(to), "data": data, "nonce": self._reserve_nonce(), - "gas": _DEFAULT_TX_GAS_LIMIT, + "gas": self._gas_limit, "gasPrice": self._w3.eth.gas_price, } signed_tx = self._account.sign_transaction(tx) tx_hash = self._w3.eth.send_raw_transaction(signed_tx.raw_transaction) - return tx_hash.hex() + return _hex_with_0x_prefix(tx_hash) def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: """Wait for a transaction to be mined. diff --git a/python/x402/mechanisms/evm/upto/facilitator.py b/python/x402/mechanisms/evm/upto/facilitator.py index f8853c8430..55844d4ade 100644 --- a/python/x402/mechanisms/evm/upto/facilitator.py +++ b/python/x402/mechanisms/evm/upto/facilitator.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Any +from ....pending_settlement_store import InMemoryPendingSettlementStore, PendingSettlementStore from ....schemas import ( Network, PaymentPayload, @@ -39,9 +40,13 @@ def __init__( self, signer: FacilitatorEvmSigner, config: UptoEvmSchemeConfig | None = None, + pending_store: PendingSettlementStore | None = None, ): self._signer = signer self._config = config or UptoEvmSchemeConfig() + self._pending_store: PendingSettlementStore = ( + pending_store or InMemoryPendingSettlementStore() + ) def get_extra(self, network: Network) -> dict[str, Any] | None: """Return facilitatorAddress so clients can bind the witness to this facilitator.""" @@ -85,4 +90,5 @@ def settle( requirements, context, simulate_in_settle=self._config.simulate_in_settle, + pending_store=self._pending_store, ) diff --git a/python/x402/mechanisms/evm/upto/permit2_utils.py b/python/x402/mechanisms/evm/upto/permit2_utils.py index 0102b7cfff..88877f47ae 100644 --- a/python/x402/mechanisms/evm/upto/permit2_utils.py +++ b/python/x402/mechanisms/evm/upto/permit2_utils.py @@ -16,6 +16,7 @@ ) from e from ....interfaces import FacilitatorContext # noqa: E402 +from ....pending_settlement_store import PendingSettlementStore # noqa: E402 from ....schemas import ( # noqa: E402 PaymentPayload, PaymentRequirements, @@ -55,9 +56,10 @@ from ..data_suffix import resolve_data_suffix # noqa: E402 from ..erc6492 import parse_erc6492_signature # noqa: E402 -# Reuse exact's allowance verification and settle error mapping +# Reuse exact's allowance verification, pending-settlement reconciliation, and settle error mapping from ..exact.permit2_utils import ( # noqa: E402 _verify_permit2_allowance, + reconcile_pending_permit2, ) from ..settle_receipt import wait_for_receipt_and_build_response # noqa: E402 from ..signer import FacilitatorEvmSigner # noqa: E402 @@ -477,11 +479,16 @@ def settle_upto_permit2( requirements: PaymentRequirements, context: FacilitatorContext | None = None, simulate_in_settle: bool = False, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle an upto Permit2 payment on-chain. The settlement amount comes from requirements.amount (set by the resource server). It must be <= the authorized maximum (permit2_authorization.permitted.amount). + + pending_store: Optional store letting a retried settle for the same payload reconcile + against an already-broadcast transaction instead of re-verifying and + re-broadcasting (see settlement_pending). """ from ....extensions.eip2612_gas_sponsoring import extract_eip2612_gas_sponsoring_info from ....extensions.erc20_approval_gas_sponsoring import ( @@ -504,6 +511,24 @@ def settle_upto_permit2( transaction="", ) + # Fast path: a prior settle attempt for this exact payload already broadcast a + # transaction whose receipt wait failed (settlement_pending). Reconcile against it + # instead of re-verifying/re-broadcasting. The resource server's retry resends the + # identical requirements, so settlement_amount above still matches the original attempt. + reconciled = reconcile_pending_permit2( + signer, + payload, + context, + pending_store, + permit2_payload.signature, + network, + payer, + ERR_UPTO_TRANSACTION_FAILED, + amount=str(settlement_amount), + ) + if reconciled is not None: + return reconciled + # Re-verify with permitted.amount (the authorized max), not the settlement amount verify_requirements = PaymentRequirements( scheme=requirements.scheme, @@ -565,6 +590,7 @@ def settle_upto_permit2( eip2612_info, settlement_amount, data_suffix=data_suffix, + pending_store=pending_store, ) # Branch: ERC-20 approval gas sponsoring @@ -581,11 +607,17 @@ def settle_upto_permit2( erc20_info, settlement_amount, data_suffix=data_suffix, + pending_store=pending_store, ) # Branch: standard settle return _settle_upto_direct( - signer, payload, permit2_payload, settlement_amount, data_suffix=data_suffix + signer, + payload, + permit2_payload, + settlement_amount, + data_suffix=data_suffix, + pending_store=pending_store, ) @@ -623,6 +655,7 @@ def _settle_upto_direct( permit2_payload: UptoPermit2Payload, settlement_amount: int, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Standard upto Permit2 settle — allowance is already on-chain.""" payer = permit2_payload.permit2_authorization.from_address @@ -652,6 +685,8 @@ def _settle_upto_direct( payer, failed_reason=ERR_UPTO_TRANSACTION_FAILED, amount=str(settlement_amount), + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: @@ -665,6 +700,7 @@ def _settle_upto_with_eip2612( eip2612_info: Any, settlement_amount: int, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle via settleWithPermit — includes the EIP-2612 permit atomically.""" payer = permit2_payload.permit2_authorization.from_address @@ -713,6 +749,8 @@ def _settle_upto_with_eip2612( payer, failed_reason=ERR_UPTO_TRANSACTION_FAILED, amount=str(settlement_amount), + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: @@ -726,6 +764,7 @@ def _settle_upto_with_erc20_approval( erc20_info: Any, settlement_amount: int, data_suffix: str | None = None, + pending_store: PendingSettlementStore | None = None, ) -> SettleResponse: """Settle via extension signer's send_transactions (approval + settle).""" payer = permit2_payload.permit2_authorization.from_address @@ -764,6 +803,8 @@ def _settle_upto_with_erc20_approval( payer, failed_reason=ERR_UPTO_TRANSACTION_FAILED, amount=str(settlement_amount), + pending_store=pending_store, + pending_key=permit2_payload.signature, ) except Exception as e: diff --git a/python/x402/mechanisms/svm/__init__.py b/python/x402/mechanisms/svm/__init__.py index 197ccfa5e6..db821c50dd 100644 --- a/python/x402/mechanisms/svm/__init__.py +++ b/python/x402/mechanisms/svm/__init__.py @@ -23,6 +23,7 @@ ERR_NETWORK_MISMATCH, ERR_NO_TRANSFER_INSTRUCTION, ERR_RECIPIENT_MISMATCH, + ERR_SETTLEMENT_PENDING, ERR_SIMULATION_FAILED, ERR_TRANSACTION_DECODE_FAILED, ERR_TRANSACTION_FAILED, @@ -145,6 +146,7 @@ "ERR_FEE_PAYER_TRANSFERRING", "ERR_SIMULATION_FAILED", "ERR_TRANSACTION_FAILED", + "ERR_SETTLEMENT_PENDING", # Types "ExactSvmPayload", "ExactSvmPayloadV1", diff --git a/python/x402/mechanisms/svm/constants.py b/python/x402/mechanisms/svm/constants.py index b7020a5c82..391ec7a012 100644 --- a/python/x402/mechanisms/svm/constants.py +++ b/python/x402/mechanisms/svm/constants.py @@ -90,6 +90,16 @@ ERR_MEMO_MISMATCH = "invalid_exact_svm_payload_memo_mismatch" ERR_MEMO_COUNT = "invalid_exact_svm_payload_memo_count" +# Non-terminal settle error reason meaning a transaction was broadcast but +# ConfirmTransaction could not observe its confirmation in time — mirrors +# EVM's ERR_SETTLEMENT_PENDING (mechanisms/evm/constants.py) and the core +# ERR_SETTLEMENT_PENDING (pending_settlement_store.py) so +# x402ResourceServer's generic single-retry-on-settlement_pending logic +# recognizes it uniformly across schemes/networks. Replaces the confirm- +# timeout previously reported as ERR_TRANSACTION_FAILED, which was terminal +# and gave callers no reconciliation path. +ERR_SETTLEMENT_PENDING = "settlement_pending" + # How long a transaction is held in the duplicate settlement cache (seconds). # Covers the Solana blockhash lifetime (~60-90s) with margin. SETTLEMENT_TTL_SECONDS = 120.0 diff --git a/python/x402/mechanisms/svm/exact/facilitator.py b/python/x402/mechanisms/svm/exact/facilitator.py index 18669a8721..c7ce5e20d7 100644 --- a/python/x402/mechanisms/svm/exact/facilitator.py +++ b/python/x402/mechanisms/svm/exact/facilitator.py @@ -12,6 +12,7 @@ "SVM mechanism requires solana packages. Install with: pip install x402[svm]" ) from e +from ....pending_settlement_store import InMemoryPendingSettlementStore, PendingSettlementStore from ....schemas import ( Network, PaymentPayload, @@ -35,6 +36,7 @@ ERR_NETWORK_MISMATCH, ERR_NO_TRANSFER_INSTRUCTION, ERR_RECIPIENT_MISMATCH, + ERR_SETTLEMENT_PENDING, ERR_SIMULATION_FAILED, ERR_TRANSACTION_DECODE_FAILED, ERR_TRANSACTION_FAILED, @@ -77,15 +79,23 @@ def __init__( self, signer: FacilitatorSvmSigner, settlement_cache: SettlementCache | None = None, + pending_store: PendingSettlementStore | None = None, ): """Create ExactSvmScheme facilitator. Args: signer: SVM signer for verification and settlement. settlement_cache: Optional shared settlement cache (one is created if omitted). + pending_store: Optional store letting a retried settle for the same + transaction reconcile against an already-broadcast signature instead of + re-verifying and re-sending (see settlement_pending). Defaults to a fresh + in-memory store when omitted. """ self._signer = signer self._settlement_cache = settlement_cache or SettlementCache() + self._pending_store: PendingSettlementStore = ( + pending_store or InMemoryPendingSettlementStore() + ) def get_extra(self, network: Network) -> dict[str, Any] | None: """Get mechanism-specific extra data for the supported kinds endpoint. @@ -379,6 +389,43 @@ def settle( svm_payload = ExactSvmPayload.from_dict(payload.payload) network = str(payload.accepted.network) + # Parse and decode the transaction up front (no RPC calls) so we can key the + # PendingSettlementStore on the message hash before doing any verify/sign/send work. + try: + tx = decode_transaction_from_payload(svm_payload) + tx_key = transaction_message_hash(tx) + except Exception as e: + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_DECODE_FAILED, + error_message=str(e), + network=network, + payer="", + transaction="", + ) + + # Pending-settlement fast path: a prior settle for this exact transaction + # broadcast successfully but its confirm_transaction wait failed. Reconcile + # against the already-broadcast signature instead of re-verifying and + # re-sending: Solana transactions embed a recent blockhash that expires (so a + # resend can fail even when the original is still perfectly valid), and if the + # original actually did land, a second verify's balance-based simulation could + # now spuriously fail (funds already moved). + cached_signature = self._pending_store.get(tx_key) + if cached_signature is not None: + # Remove before reconciling (rather than after) so a concurrent + # retry of the same payload misses here instead of also + # reconciling: it falls through to the settlement_cache dedup + # check below, which independently rejects it as a duplicate. + self._pending_store.delete(tx_key) + # Best-effort payer for the response; a lookup failure here doesn't block + # reconciliation (the payload already broadcast successfully). + try: + payer = get_token_payer_from_transaction(tx) or "" + except Exception: + payer = "" + return self._reconcile_pending_settlement(tx_key, cached_signature, payer, network) + # First verify verify_result = self.verify(payload, requirements, context) if not verify_result.is_valid: @@ -390,11 +437,7 @@ def settle( transaction="", ) - # Decode the transaction to compute the message hash used as the cache key. - tx = decode_transaction_from_payload(svm_payload) - # Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). - tx_key = transaction_message_hash(tx) if self._settlement_cache.is_duplicate(tx_key): return SettleResponse( success=False, @@ -404,7 +447,6 @@ def settle( transaction="", ) - signature = "" try: # Extract feePayer from requirements (already validated in verify) extra = requirements.extra or {} @@ -414,27 +456,102 @@ def settle( fully_signed_tx = self._signer.sign_transaction( svm_payload.transaction, fee_payer, network ) + except Exception as e: + self._settlement_cache.delete(tx_key) + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_FAILED, + error_message=str(e), + transaction="", + network=network, + payer=verify_result.payer or "", + ) + try: # Send transaction to network signature = self._signer.send_transaction(fully_signed_tx, network) - - # Wait for confirmation - self._signer.confirm_transaction(signature, network) - + except Exception as e: + self._settlement_cache.delete(tx_key) return SettleResponse( - success=True, - transaction=signature, + success=False, + error_reason=ERR_TRANSACTION_FAILED, + error_message=str(e), + transaction="", network=network, - payer=verify_result.payer, + payer=verify_result.payer or "", ) + # Wait for confirmation, shared with the pending-settlement reconciliation + # path in _reconcile_pending_settlement() below. + return self._await_confirmation(tx_key, signature, verify_result.payer or "", network) + + def _reconcile_pending_settlement( + self, + tx_key: str, + signature: str, + payer: str, + network: str, + ) -> SettleResponse: + """Handle a PendingSettlementStore cache hit. + + A prior settle call for this transaction (keyed by tx_key, the message hash) + already broadcast `signature` but couldn't confirm it before returning + settlement_pending. Re-awaits confirmation of that same signature rather than + re-verifying/re-signing/re-sending — see the fast-path comment in settle() for + why re-sending is unsafe here. + """ + return self._await_confirmation(tx_key, signature, payer, network) + + def _await_confirmation( + self, + tx_key: str, + signature: str, + payer: str, + network: str, + ) -> SettleResponse: + """Waits for confirmation of an already-broadcast signature and builds the + settle response, shared by the fresh-broadcast path in settle() and the + pending-settlement reconciliation path in _reconcile_pending_settlement(). + + On confirm failure, records/refreshes the pending-settlement entry so a + retry reconciles via the fast path instead of re-verifying/re-sending. + """ + try: + self._signer.confirm_transaction(signature, network) except Exception as e: - self._settlement_cache.delete(tx_key) + try: + self._pending_store.set(tx_key, signature) + except Exception as store_error: + # Can't guarantee a later retry will find this to reconcile + # against — a blind retry could re-verify/re-broadcast and + # double-send. Downgrade to terminal, preserving the signature + # for manual reconciliation. + return SettleResponse( + success=False, + error_reason=ERR_TRANSACTION_FAILED, + error_message=( + f"settlement_pending, but failed to persist for retry: {store_error}" + ), + transaction=signature, + network=network, + payer=payer, + ) return SettleResponse( success=False, - error_reason=ERR_TRANSACTION_FAILED, + error_reason=ERR_SETTLEMENT_PENDING, error_message=str(e), transaction=signature, network=network, - payer=verify_result.payer or "", + payer=payer, ) + + try: + self._pending_store.delete(tx_key) + except Exception: + pass # best-effort; a stale entry merely lingers until TTL expiry + return SettleResponse( + success=True, + transaction=signature, + network=network, + payer=payer, + ) diff --git a/python/x402/mechanisms/svm/exact/server.py b/python/x402/mechanisms/svm/exact/server.py index 66367e0589..7e8b7b4eb5 100644 --- a/python/x402/mechanisms/svm/exact/server.py +++ b/python/x402/mechanisms/svm/exact/server.py @@ -31,7 +31,7 @@ class ExactSvmScheme: scheme = SCHEME_EXACT default_asset_transfer_method = "default" payment_flows = { - "default": {"supported": ("authorization",), "default": "authorization"}, + "default": {"supported": ("authorization", "upfront"), "default": "authorization"}, } dynamic_extra_fields = ["recentBlockhash", "lastValidBlockHeight"] diff --git a/python/x402/mechanisms/svm/settlement_cache.py b/python/x402/mechanisms/svm/settlement_cache.py index 8fdc4bb974..ba793aa534 100644 --- a/python/x402/mechanisms/svm/settlement_cache.py +++ b/python/x402/mechanisms/svm/settlement_cache.py @@ -35,7 +35,12 @@ def is_duplicate(self, key: str) -> bool: return False def delete(self, key: str) -> None: - """Remove *key* so a later settle can retry before TTL expiry.""" + """Remove key's dedup entry, e.g. after a broadcast/send failure. + + Lets a subsequent settle attempt for the same key (a genuine retry, not a + duplicate) proceed rather than being rejected as a duplicate forever until the TTL + elapses. + """ with self._lock: self._entries.pop(key, None) diff --git a/python/x402/mechanisms/tvm/exact/server.py b/python/x402/mechanisms/tvm/exact/server.py index 4a80dd21ff..4bff9f8c1b 100644 --- a/python/x402/mechanisms/tvm/exact/server.py +++ b/python/x402/mechanisms/tvm/exact/server.py @@ -26,7 +26,7 @@ class ExactTvmScheme: scheme = SCHEME_EXACT default_asset_transfer_method = "default" payment_flows = { - "default": {"supported": ("authorization",), "default": "authorization"}, + "default": {"supported": ("authorization", "upfront"), "default": "authorization"}, } def __init__(self) -> None: diff --git a/python/x402/pending_settlement_store.py b/python/x402/pending_settlement_store.py new file mode 100644 index 0000000000..a4a6e578f1 --- /dev/null +++ b/python/x402/pending_settlement_store.py @@ -0,0 +1,139 @@ +"""Pending-settlement store shared by EVM and SVM facilitator mechanisms. + +Lets a facilitator-side mechanism remember a broadcast-but-not-yet-confirmed +transaction hash, keyed by a deterministic identifier derived from the payment +payload (e.g. an EIP-3009/Permit2 signature, or an SVM message hash). When a +settle attempt's receipt/confirmation wait fails, the mechanism stores the +broadcast hash here before returning a ``settlement_pending`` error. On a +subsequent settle attempt for the same payload (typically the resource +server's single automatic retry — see ``settle_with_pending_retry`` in +``server_base.py``), the mechanism checks this store first and, on a hit, +reconciles against the already-broadcast transaction instead of verifying and +broadcasting a second one. + +``PendingSettlementStore`` is a ``Protocol`` — not a concrete type — +specifically so a multi-instance facilitator (running several replicas with +no session affinity) can supply a shared, network-backed implementation (e.g. +Redis) instead of the in-memory default, which only works when a retry +happens to land back on the same process. Implementations must be safe for +concurrent use. + +Unlike the Go/TypeScript SDKs (where facilitator mechanism code is +network-bound and asynchronous throughout), this Python SDK's mechanism layer +(``verify``/``settle`` on every scheme class) is fully synchronous — see +``ExactEvmScheme.settle``, ``ExactSvmScheme.settle``, etc. This Protocol is +therefore intentionally synchronous too, matching the existing +``SettlementCache`` convention (``mechanisms/svm/settlement_cache.py``), so +mechanism code never needs to bridge sync/async. A network-backed +implementation is free to perform blocking I/O inside these methods. +""" + +from __future__ import annotations + +import threading +import time +from typing import Protocol, runtime_checkable + +# Generic (scheme/network-agnostic) settle error reason meaning a transaction +# broadcast successfully but its receipt/confirmation wait failed — +# non-terminal, and always carries the broadcast transaction hash so a caller +# can reconcile onchain. Mirrors the EVM-scoped ERR_SETTLEMENT_PENDING +# constant (mechanisms/evm/constants.py); duplicated here as a plain string so +# core server/retry logic does not depend on the EVM mechanism package. +ERR_SETTLEMENT_PENDING = "settlement_pending" + +# TTL applied by the default in-memory PendingSettlementStore implementation. +# A store implementation backed by a different mechanism (e.g. Redis, for a +# multi-instance facilitator) is free to use its own TTL — this constant only +# governs InMemoryPendingSettlementStore. +PENDING_SETTLEMENT_TTL_SECONDS = 300.0 + + +@runtime_checkable +class PendingSettlementStore(Protocol): + """Protocol for a pending-settlement store. + + Implementations must be safe for concurrent use. + """ + + def get(self, key: str) -> str | None: + """Return the previously stored transaction hash for key, if any. + + Returns None when there is no entry (including one that has expired). + """ + ... + + def set(self, key: str, tx_hash: str) -> None: + """Record that key's payment broadcast tx_hash but is not yet confirmed. + + A subsequent set() for the same key overwrites the prior value. + """ + ... + + def delete(self, key: str) -> None: + """Remove any pending entry for key. + + Called once the transaction is confirmed (success) or the mechanism + determines it terminally failed. + """ + ... + + +class InMemoryPendingSettlementStore: + """Default PendingSettlementStore implementation. + + A lock-protected, per-process dict with lazy TTL pruning (mirrors the + shape of ``mechanisms/svm/settlement_cache.SettlementCache``). Never + performs network I/O — ``get`` additionally prunes expired entries (O(n) + in the number of currently-stored entries, which stays small since + entries only exist while a settlement is genuinely pending), so every + call adds no meaningful latency to the settle hot path. + + Suitable for single-instance facilitators; multi-instance deployments + should inject a shared, network-backed PendingSettlementStore + implementation instead (e.g. Redis). + """ + + def __init__(self) -> None: + self._entries: dict[str, tuple[str, float]] = {} + self._lock = threading.Lock() + + def get(self, key: str) -> str | None: + with self._lock: + self._prune() + entry = self._entries.get(key) + return entry[0] if entry is not None else None + + def set(self, key: str, tx_hash: str) -> None: + with self._lock: + self._entries[key] = (tx_hash, time.monotonic()) + + def delete(self, key: str) -> None: + with self._lock: + self._entries.pop(key, None) + + @property + def entries(self) -> dict[str, tuple[str, float]]: + """Direct access to the underlying dict — use only in tests.""" + return self._entries + + def _prune(self) -> None: + """Remove entries older than the TTL. Caller must hold ``_lock``. + + A set() can refresh an existing key's timestamp without moving its + position in the dict, so (unlike SettlementCache, which only ever + inserts new keys) this scans every entry rather than assuming + insertion-order timestamps and breaking early. + """ + cutoff = time.monotonic() - PENDING_SETTLEMENT_TTL_SECONDS + expired = [key for key, (_, stored_at) in self._entries.items() if stored_at < cutoff] + for key in expired: + del self._entries[key] + + +__all__ = [ + "ERR_SETTLEMENT_PENDING", + "PENDING_SETTLEMENT_TTL_SECONDS", + "PendingSettlementStore", + "InMemoryPendingSettlementStore", +] diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index cce6db7513..f2a9220cba 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "x402" -version = "2.20.0" +version = "2.21.0" description = "x402 Payment Protocol SDK for Python" readme = "README.md" license = { text = "MIT" } @@ -142,6 +142,8 @@ exclude = [ "facilitator.py" = "x402/facilitator.py" "facilitator_base.py" = "x402/facilitator_base.py" "interfaces.py" = "x402/interfaces.py" +"payment_flow.py" = "x402/payment_flow.py" +"pending_settlement_store.py" = "x402/pending_settlement_store.py" "py.typed" = "x402/py.typed" "schemas" = "x402/schemas" "http" = "x402/http" diff --git a/python/x402/schemas/hooks.py b/python/x402/schemas/hooks.py index b28362f57d..56af754a68 100644 --- a/python/x402/schemas/hooks.py +++ b/python/x402/schemas/hooks.py @@ -263,6 +263,10 @@ def __post_init__(self) -> None: if self.result is None: raise ValueError("result is required for VerifyResultContext") + @property + def extension_responses(self) -> dict[str, Any] | None: + return self.result.extension_responses + @dataclass class VerifyFailureContext(VerifyContext): @@ -319,6 +323,10 @@ def __post_init__(self) -> None: if self.result is None: raise ValueError("result is required for SettleResultContext") + @property + def extension_responses(self) -> dict[str, Any] | None: + return self.result.extension_responses + @dataclass class SettleFailureContext(SettleContext): diff --git a/python/x402/schemas/responses.py b/python/x402/schemas/responses.py index d96b3f0d11..bd402dfd3a 100644 --- a/python/x402/schemas/responses.py +++ b/python/x402/schemas/responses.py @@ -37,6 +37,11 @@ class VerifyResponse(BaseX402Model): invalid_message: str | None = None payer: str | None = None extensions: dict[str, Any] | None = None + extension_responses: dict[str, Any] | None = Field( + default=None, + exclude=True, + description="Facilitator extension sidechannel; not serialized to buyers.", + ) extra: dict[str, Any] | None = None @@ -75,6 +80,11 @@ class SettleResponse(BaseX402Model): network: Network amount: str | None = None extensions: dict[str, Any] | None = None + extension_responses: dict[str, Any] | None = Field( + default=None, + exclude=True, + description="Facilitator extension sidechannel; not serialized to buyers.", + ) extra: dict[str, Any] | None = None diff --git a/python/x402/server.py b/python/x402/server.py index 088e6f5a17..e8b2f3e9b2 100644 --- a/python/x402/server.py +++ b/python/x402/server.py @@ -43,6 +43,8 @@ SyncOnSettleFailureHook, SyncOnVerifiedPaymentCanceledHook, SyncOnVerifyFailureHook, + settle_with_pending_retry, + settle_with_pending_retry_async, x402ResourceServerBase, ) @@ -291,7 +293,7 @@ async def settle_payment( if method_name == "verify": result = await target.verify(p, r) else: - result = await target.settle(p, r) + result = await settle_with_pending_retry_async(target, p, r) else: result = await self._execute_hook(target, ctx) except StopIteration as e: @@ -611,7 +613,7 @@ def settle_payment( if method_name == "verify": result = target.verify(p, r) else: - result = target.settle(p, r) + result = settle_with_pending_retry(target, p, r) else: result = self._execute_hook_sync(target, ctx) except StopIteration as e: diff --git a/python/x402/server_base.py b/python/x402/server_base.py index 380af2f4dc..907b5cb7d5 100644 --- a/python/x402/server_base.py +++ b/python/x402/server_base.py @@ -34,6 +34,7 @@ resolve_payment_flow, resolve_payment_flow_phases, ) +from .pending_settlement_store import ERR_SETTLEMENT_PENDING from .schemas import ( X402_VERSION, AbortResult, @@ -291,6 +292,66 @@ def get_supported(self) -> SupportedResponse: ... +# ============================================================================ +# Single automatic settle retry on settlement_pending +# ============================================================================ +# +# When a settle attempt's receipt/confirmation wait fails, a mechanism (see +# PendingSettlementStore in pending_settlement_store.py) returns success=False +# with error_reason="settlement_pending" and a populated `transaction` hash. +# The resource server resends the identical payload/requirements exactly once +# so the mechanism's own PendingSettlementStore check reconciles against the +# already-broadcast transaction instead of verifying and broadcasting a +# second one. No mutation, backoff, or sleep here — the mechanism layer owns +# any bounded waiting. Any other outcome (success, or a different failure +# reason) short-circuits after the first call, and this is capped at exactly +# one retry regardless of the second outcome, so it can never loop. +# +# This lives once, above all scheme/network dispatch, in the driver loops +# that call `client.settle(...)` at the "call_facilitator" phase of the +# settle generator (see `settle_payment` in server.py, both sync and async). + + +def is_retryable_settlement_pending(result: SettleResponse) -> bool: + """Report whether a settle result is a retryable settlement_pending. + + True when the result is a non-terminal settlement_pending failure that + carries a broadcast transaction hash to reconcile against. + """ + return ( + not result.success + and result.error_reason == ERR_SETTLEMENT_PENDING + and bool(result.transaction) + ) + + +def settle_with_pending_retry( + client: FacilitatorClientSync, + payload: PaymentPayload | PaymentPayloadV1, + requirements: PaymentRequirements | PaymentRequirementsV1, +) -> SettleResponse: + """Call client.settle once, retrying exactly once on settlement_pending. + + See the module-level comment above for the retry semantics. + """ + result = client.settle(payload, requirements) + if not is_retryable_settlement_pending(result): + return result + return client.settle(payload, requirements) + + +async def settle_with_pending_retry_async( + client: FacilitatorClient, + payload: PaymentPayload | PaymentPayloadV1, + requirements: PaymentRequirements | PaymentRequirementsV1, +) -> SettleResponse: + """Async counterpart of settle_with_pending_retry.""" + result = await client.settle(payload, requirements) + if not is_retryable_settlement_pending(result): + return result + return await client.settle(payload, requirements) + + # ============================================================================ # Type Aliases - Support both sync and async hooks # ============================================================================ diff --git a/python/x402/tests/integrations/_settlement_pending_helpers.py b/python/x402/tests/integrations/_settlement_pending_helpers.py new file mode 100644 index 0000000000..5a480949cf --- /dev/null +++ b/python/x402/tests/integrations/_settlement_pending_helpers.py @@ -0,0 +1,45 @@ +"""Shared helpers for the settlement_pending integration tests +(test_evm_settlement_pending.py, test_svm_settlement_pending.py, +test_evm_batch_settlement_settlement_pending.py). +""" + +from __future__ import annotations + +from typing import Any + +from x402.schemas import PaymentPayload, PaymentRequirements, SettleResponse, VerifyResponse + + +class SingleSchemeFacilitatorClientSync: + """Adapts a single-scheme, single-network facilitator scheme (verify/settle/get_extra/ + get_signers) to the FacilitatorClient surface used by x402ResourceServerSync. + """ + + x402_version = 2 + + def __init__(self, scheme: str, network: str, facilitator_scheme: Any) -> None: + self.scheme = scheme + self.network = network + self._facilitator_scheme = facilitator_scheme + + def verify(self, payload: PaymentPayload, requirements: PaymentRequirements) -> VerifyResponse: + return self._facilitator_scheme.verify(payload, requirements) + + def settle(self, payload: PaymentPayload, requirements: PaymentRequirements) -> SettleResponse: + return self._facilitator_scheme.settle(payload, requirements) + + def get_supported(self): + from x402.schemas import SupportedKind, SupportedResponse + + return SupportedResponse( + kinds=[ + SupportedKind( + x402_version=self.x402_version, + scheme=self.scheme, + network=self.network, + extra=self._facilitator_scheme.get_extra(self.network), + ) + ], + extensions=[], + signers={self.network: self._facilitator_scheme.get_signers(self.network)}, + ) diff --git a/python/x402/tests/integrations/test_evm_batch_settlement_settlement_pending.py b/python/x402/tests/integrations/test_evm_batch_settlement_settlement_pending.py new file mode 100644 index 0000000000..ea352b4815 --- /dev/null +++ b/python/x402/tests/integrations/test_evm_batch_settlement_settlement_pending.py @@ -0,0 +1,297 @@ +"""EVM batch-settlement integration tests for the settlement-pending auto-recovery +mechanism, applied to the deposit settle path (`deposit.py::settle_deposit` / +`_reconcile_pending_deposit`). + +Follows the same `ForcedPendingReceiptSigner` decorator pattern as +`test_evm_settlement_pending.py` (itself mirroring the Go integration tests in +go/test/integration/evm_test.go): every broadcast is a real Base Sepolia transaction, +but `wait_for_transaction_receipt` is deterministically forceable to fail on demand via a +mutable flag, so the settlement_pending reconciliation path can be exercised without +racing real chain confirmation speed. + +Required environment variables: +- EVM_CLIENT_PRIVATE_KEY (or EVM_CLIENT_EOA_PRIVATE_KEY): Private key for the client (payer). +- EVM_FACILITATOR_PRIVATE_KEY: Private key for the facilitator submitting txs. +Optional: +- EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY: defaults to EVM_FACILITATOR_PRIVATE_KEY. +- EVM_RPC_URL: defaults to https://sepolia.base.org. + +WARNING: Every test in this file makes a REAL on-chain deposit transaction. +""" + +from __future__ import annotations + +import os +import secrets +from typing import Any + +import pytest +from eth_account import Account + +from x402 import x402ClientSync, x402ResourceServerSync +from x402.mechanisms.evm.batch_settlement import SCHEME_BATCH_SETTLEMENT +from x402.mechanisms.evm.batch_settlement.authorizer_signer import LocalAuthorizerSigner +from x402.mechanisms.evm.batch_settlement.client import ( + BatchSettlementDepositPolicy, + BatchSettlementEvmSchemeOptions, + InMemoryClientChannelStorage, +) +from x402.mechanisms.evm.batch_settlement.client import ( + BatchSettlementEvmScheme as BatchSettlementClientScheme, +) +from x402.mechanisms.evm.batch_settlement.facilitator import BatchSettlementEvmFacilitator +from x402.mechanisms.evm.batch_settlement.server import ( + BatchSettlementEvmScheme as BatchSettlementServerScheme, +) +from x402.mechanisms.evm.batch_settlement.server import ( + BatchSettlementEvmSchemeServerConfig, +) +from x402.mechanisms.evm.constants import ERR_SETTLEMENT_PENDING +from x402.mechanisms.evm.signers import EthAccountSigner, FacilitatorWeb3Signer +from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo + +from ._settlement_pending_helpers import SingleSchemeFacilitatorClientSync + +CLIENT_PRIVATE_KEY = os.environ.get("EVM_CLIENT_EOA_PRIVATE_KEY") or os.environ.get( + "EVM_CLIENT_PRIVATE_KEY" +) +FACILITATOR_PRIVATE_KEY = os.environ.get("EVM_FACILITATOR_PRIVATE_KEY") +RECEIVER_AUTHORIZER_PRIVATE_KEY = os.environ.get( + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", FACILITATOR_PRIVATE_KEY +) + +RPC_URL = os.environ.get("EVM_RPC_URL", "https://sepolia.base.org") +NETWORK = "eip155:84532" +USDC_ADDRESS = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +pytestmark = pytest.mark.skipif( + not CLIENT_PRIVATE_KEY or not FACILITATOR_PRIVATE_KEY, + reason=( + "EVM_CLIENT_EOA_PRIVATE_KEY (or EVM_CLIENT_PRIVATE_KEY) and EVM_FACILITATOR_PRIVATE_KEY " + "environment variables required for batch-settlement settlement_pending integration tests" + ), +) + + +class ForcedPendingReceiptSigner: + """Wraps a real `FacilitatorEvmSigner` and, while `force_pending` is True, makes + `wait_for_transaction_receipt` fail immediately instead of delegating to the real + (network-speed-dependent) receipt wait. Every other method delegates unmodified to + the wrapped signer via `__getattr__`, so every broadcast is always real. + + `fail_next_n_waits`, when set to a positive count, forces exactly that many + subsequent `wait_for_transaction_receipt` calls to fail (decrementing on each + call) before delegating to the real wait again — used by the resource-server retry + test below to force only the *first* of the two calls the SDK's single automatic + retry makes within one `settle_payment()` call, without needing a second top-level + call (which would race the batch-settlement server scheme's own channel-busy + tracking, an unrelated concern from a different top-level request). + """ + + def __init__(self, wrapped: FacilitatorWeb3Signer) -> None: + self._wrapped = wrapped + self.force_pending = False + self.fail_next_n_waits = 0 + self.write_contract_calls = 0 + + def wait_for_transaction_receipt(self, tx_hash: str) -> Any: + if self.force_pending or self.fail_next_n_waits > 0: + self.fail_next_n_waits = max(0, self.fail_next_n_waits - 1) + raise TimeoutError( + "forced receipt-wait failure for settlement_pending integration test" + ) + return self._wrapped.wait_for_transaction_receipt(tx_hash) + + def write_contract(self, *args: Any, **kwargs: Any) -> str: + self.write_contract_calls += 1 + return self._wrapped.write_contract(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _build_requirements(pay_to: str, amount: str, receiver_authorizer: str) -> PaymentRequirements: + return PaymentRequirements( + scheme=SCHEME_BATCH_SETTLEMENT, + network=NETWORK, + asset=USDC_ADDRESS, + amount=amount, + pay_to=pay_to, + max_timeout_seconds=3600, + extra={ + "name": "USDC", + "version": "2", + "assetTransferMethod": "eip3009", + "receiverAuthorizer": receiver_authorizer, + }, + ) + + +def _build_deposit_payload( + facilitator_address: str, receiver_authorizer: str +) -> tuple[PaymentPayload, PaymentRequirements]: + """Create a real, signed batch-settlement deposit payload.""" + from x402.schemas import PaymentRequired + + client_account = Account.from_key(CLIENT_PRIVATE_KEY) + client_signer = EthAccountSigner(client_account) + channel_salt = "0x" + secrets.token_bytes(32).hex() + client_storage = InMemoryClientChannelStorage() + client = x402ClientSync().register( + NETWORK, + BatchSettlementClientScheme( + client_signer, + BatchSettlementEvmSchemeOptions( + storage=client_storage, + salt=channel_salt, + deposit_policy=BatchSettlementDepositPolicy(deposit_multiplier=3), + ), + ), + ) + + requirements = _build_requirements(facilitator_address, "1000", receiver_authorizer) + resource = ResourceInfo( + url="https://example.com/api", + description="Settlement-pending integration test resource", + mime_type="application/json", + ) + payment_required = PaymentRequired(x402_version=2, resource=resource, accepts=[requirements]) + payload = client.create_payment_payload(payment_required) + assert payload.payload.get("type") == "deposit" + return payload, requirements + + +class TestEvmBatchSettlementDepositSettlementPendingReconciliation: + """Exercises the settlement-pending mechanism layer directly against a real + on-chain batch-settlement deposit, mirroring the shape of + `TestEvmSettlementPendingReconciliation` (test_evm_settlement_pending.py) applied to + `BatchSettlementEvmFacilitator.settle`. + """ + + def test_reconciles_against_the_same_broadcast_transaction(self) -> None: + """The first Settle call broadcasts the deposit for real but is forced to fail + its receipt wait, producing a settlement_pending SettleResponse with the + broadcast hash attached and a PendingSettlementStore entry populated (keyed by + the deposit's ERC-3009 authorization signature). A second Settle call with the + identical payload, now with receipt-waiting un-forced, must hit the pending-store + fast path (skip verify/broadcast) and reconcile against that already-broadcast + transaction, returning success with the SAME transaction hash as the first + attempt. + + WARNING: This spends real Base Sepolia USDC on a deposit. + """ + real_facilitator_signer = FacilitatorWeb3Signer( + private_key=FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingReceiptSigner(real_facilitator_signer) + authorizer_signer = LocalAuthorizerSigner(RECEIVER_AUTHORIZER_PRIVATE_KEY) + facilitator_scheme = BatchSettlementEvmFacilitator( + facilitator_signer, + authorizer_signer, # type: ignore[arg-type] + ) + + payload, requirements = _build_deposit_payload( + real_facilitator_signer.address, authorizer_signer.address + ) + + facilitator_signer.force_pending = True + + first = facilitator_scheme.settle(payload, requirements) + assert first.success is False, f"expected settlement_pending, got: {first}" + assert first.error_reason == ERR_SETTLEMENT_PENDING + assert first.transaction, ( + "expected a broadcast transaction hash on the settlement_pending result" + ) + first_tx_hash = first.transaction + assert facilitator_signer.write_contract_calls == 1 + + facilitator_signer.force_pending = False + + second = facilitator_scheme.settle(payload, requirements) + assert second.success is True, ( + f"expected the reconciliation settle to succeed, got: {second}" + ) + assert second.transaction == first_tx_hash, ( + "reconciliation must reuse the already-broadcast transaction (no second " + f"broadcast): first={first_tx_hash} second={second.transaction}" + ) + # Still exactly one broadcast across both attempts. + assert facilitator_signer.write_contract_calls == 1 + + +class TestEvmBatchSettlementResourceServerSettlementPendingRetry: + """Exercises the generic `x402ResourceServerSync.settle_payment` single-retry-on- + settlement_pending path (`settle_with_pending_retry` in server_base.py) against a + real batch-settlement deposit broadcast, entirely within one top-level + `settle_payment()` call (the retry is internal/transparent to the caller — see + `ForcedPendingReceiptSigner`'s docstring for why this test forces only the first of + the two internal attempts to fail, rather than making two top-level calls). + """ + + def test_single_internal_retry_reconciles_without_a_second_broadcast(self) -> None: + """The resource server's `settle_payment` calls the facilitator client's + `settle` up to twice internally: the initial attempt, and (only if it returned + settlement_pending) exactly one automatic retry. Forcing only the first of the + two `wait_for_transaction_receipt` calls to fail means: the initial internal + attempt broadcasts for real but observes settlement_pending, and the automatic + retry — resending the identical payload — must hit the pending-store fast path + (skip verify/broadcast) and reconcile against that already-broadcast + transaction, so the top-level call returns success having broadcast exactly + once. + + WARNING: This spends real Base Sepolia USDC on a deposit. + """ + real_facilitator_signer = FacilitatorWeb3Signer( + private_key=FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingReceiptSigner(real_facilitator_signer) + authorizer_signer = LocalAuthorizerSigner(RECEIVER_AUTHORIZER_PRIVATE_KEY) + facilitator_scheme = BatchSettlementEvmFacilitator( + facilitator_signer, + authorizer_signer, # type: ignore[arg-type] + ) + + facilitator_client = SingleSchemeFacilitatorClientSync( + SCHEME_BATCH_SETTLEMENT, NETWORK, facilitator_scheme + ) + server = x402ResourceServerSync(facilitator_client) + server.register( + NETWORK, + BatchSettlementServerScheme( + real_facilitator_signer.address, + BatchSettlementEvmSchemeServerConfig( + receiver_authorizer_signer=authorizer_signer, + ), + ), + ) + server.initialize() + + payload, requirements = _build_deposit_payload( + real_facilitator_signer.address, authorizer_signer.address + ) + accepted = server.find_matching_requirements([requirements], payload) + assert accepted is not None + + # The server's channel-busy tracking (a request_context keyed by id(payload), + # taken during verify) requires a verify call before settle — matching the real + # HTTP request flow (and test_evm_batch_settlement.py's pattern) — before the + # settlement_pending mechanics under test even come into play. + verify_response = server.verify_payment(payload, accepted) + assert verify_response.is_valid is True, f"verify failed: {verify_response.invalid_reason}" + + # Fail exactly the first wait_for_transaction_receipt call (the initial + # internal attempt); the retry's wait (against the pending-store's cached + # hash) is left un-forced so it observes the real confirmation. + facilitator_signer.fail_next_n_waits = 1 + + result = server.settle_payment(payload, accepted) + + assert result.success is True, ( + "expected the resource server's single automatic retry to reconcile " + f"against the already-broadcast transaction and succeed, got: {result}" + ) + assert result.transaction, "expected a transaction hash on the successful settlement" + assert facilitator_signer.write_contract_calls == 1, ( + "the automatic retry must reconcile against the pending-store fast path " + "rather than broadcasting a second deposit transaction" + ) diff --git a/python/x402/tests/integrations/test_evm_settlement_pending.py b/python/x402/tests/integrations/test_evm_settlement_pending.py new file mode 100644 index 0000000000..6dd8a2e70f --- /dev/null +++ b/python/x402/tests/integrations/test_evm_settlement_pending.py @@ -0,0 +1,253 @@ +"""EVM integration tests for the settlement-pending auto-recovery mechanism. + +Mirrors the Go integration tests `TestEVMIntegrationV2_SettlementPendingReconciliation` +and `TestEVMIntegrationV2_ResourceServerSettlementPendingRetry` +(go/test/integration/evm_test.go), which are themselves the reference for this pattern. + +Uses `ForcedPendingReceiptSigner`, a decorator around the real `FacilitatorWeb3Signer` +that forces just `wait_for_transaction_receipt` to fail on demand via a mutable flag, +while every other signer method (`write_contract`, `send_transaction`, `get_code`, etc.) +delegates unmodified to the real signer. This means every broadcast in these tests is a +real Base Sepolia transaction, but the receipt/confirm wait is deterministically +forceable — avoiding any dependency on real chain confirmation speed (empirically, +racing a short timeout against real confirmations is unreliable since confirmations can +land in ~1s). + +Required environment variables: +- EVM_CLIENT_PRIVATE_KEY (or EVM_CLIENT_EOA_PRIVATE_KEY): Private key for the client (payer). +- EVM_FACILITATOR_PRIVATE_KEY: Private key for the facilitator submitting txs. +- EVM_RESOURCE_SERVER_ADDRESS: Recipient address for the settled payment. + +These must be funded accounts on Base Sepolia with USDC. + +WARNING: Every test in this file makes a REAL on-chain transaction. +""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest +from eth_account import Account + +from x402 import x402ClientSync, x402ResourceServerSync +from x402.mechanisms.evm import SCHEME_EXACT +from x402.mechanisms.evm.constants import ERR_SETTLEMENT_PENDING +from x402.mechanisms.evm.exact import ( + ExactEvmClientScheme, + ExactEvmFacilitatorScheme, + ExactEvmSchemeConfig, + ExactEvmServerScheme, +) +from x402.mechanisms.evm.signers import EthAccountSigner, FacilitatorWeb3Signer +from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo + +from ._settlement_pending_helpers import SingleSchemeFacilitatorClientSync + +CLIENT_PRIVATE_KEY = os.environ.get("EVM_CLIENT_EOA_PRIVATE_KEY") or os.environ.get( + "EVM_CLIENT_PRIVATE_KEY" +) +FACILITATOR_PRIVATE_KEY = os.environ.get("EVM_FACILITATOR_PRIVATE_KEY") +RESOURCE_SERVER_ADDRESS = os.environ.get("EVM_RESOURCE_SERVER_ADDRESS") + +RPC_URL = os.environ.get("EVM_RPC_URL", "https://sepolia.base.org") +NETWORK = "eip155:84532" +USDC_ADDRESS = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +pytestmark = pytest.mark.skipif( + not CLIENT_PRIVATE_KEY or not FACILITATOR_PRIVATE_KEY or not RESOURCE_SERVER_ADDRESS, + reason=( + "EVM_CLIENT_EOA_PRIVATE_KEY (or EVM_CLIENT_PRIVATE_KEY), EVM_FACILITATOR_PRIVATE_KEY, " + "and EVM_RESOURCE_SERVER_ADDRESS environment variables required for settlement_pending " + "integration tests" + ), +) + + +class ForcedPendingReceiptSigner: + """Wraps a real `FacilitatorEvmSigner` and, while `force_pending` is True, makes + `wait_for_transaction_receipt` fail immediately instead of delegating to the real + (network-speed-dependent) receipt wait. Every other attribute/method (write_contract, + send_transaction, get_code, read_contract, get_addresses, etc.) delegates unmodified + to the wrapped signer via `__getattr__`, so every broadcast is always real. + """ + + def __init__(self, wrapped: FacilitatorWeb3Signer) -> None: + self._wrapped = wrapped + self.force_pending = False + self.write_contract_calls = 0 + + def wait_for_transaction_receipt(self, tx_hash: str) -> Any: + if self.force_pending: + raise TimeoutError( + "forced receipt-wait failure for settlement_pending integration test" + ) + return self._wrapped.wait_for_transaction_receipt(tx_hash) + + def write_contract(self, *args: Any, **kwargs: Any) -> str: + self.write_contract_calls += 1 + return self._wrapped.write_contract(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _build_requirements(pay_to: str, amount: str) -> PaymentRequirements: + return PaymentRequirements( + scheme=SCHEME_EXACT, + network=NETWORK, + asset=USDC_ADDRESS, + amount=amount, + pay_to=pay_to, + max_timeout_seconds=3600, + extra={"name": "USDC", "version": "2"}, + ) + + +def _build_payment_payload() -> tuple[PaymentPayload, PaymentRequirements, str]: + """Create a real, signed EIP-3009 payment payload shared by the tests below.""" + from x402.schemas import PaymentRequired + + client_account = Account.from_key(CLIENT_PRIVATE_KEY) + client_signer = EthAccountSigner(client_account) + client = x402ClientSync().register(NETWORK, ExactEvmClientScheme(client_signer)) + client.set_spend_controls(False) + + requirements = _build_requirements(RESOURCE_SERVER_ADDRESS, "1000") + resource = ResourceInfo( + url="https://api.example.com/premium", + description="Premium API Access", + mime_type="application/json", + ) + payment_required = PaymentRequired(x402_version=2, resource=resource, accepts=[requirements]) + payload = client.create_payment_payload(payment_required) + return payload, requirements, client_signer.address + + +class TestEvmSettlementPendingReconciliation: + """Exercises the settlement-pending mechanism layer directly against a real + on-chain EIP-3009 settlement, mirroring + `TestEVMIntegrationV2_SettlementPendingReconciliation` in go/test/integration/evm_test.go. + """ + + def test_reconciles_against_the_same_broadcast_transaction(self) -> None: + """The first Settle call broadcasts for real but is forced to fail its receipt + wait, producing a settlement_pending SettleResponse with the broadcast hash + attached and a PendingSettlementStore entry populated. A second Settle call with + the identical payload, now with receipt-waiting un-forced, must hit the + pending-store fast path (skip verify/broadcast) and reconcile against that + already-broadcast transaction, returning success with the SAME transaction hash + as the first attempt — proving no second transaction was ever broadcast. + + WARNING: This spends real Base Sepolia USDC. + """ + real_facilitator_signer = FacilitatorWeb3Signer( + private_key=FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingReceiptSigner(real_facilitator_signer) + facilitator_scheme = ExactEvmFacilitatorScheme( + facilitator_signer, + ExactEvmSchemeConfig(), # type: ignore[arg-type] + ) + + payload, requirements, payer_address = _build_payment_payload() + + # Attempt 1: broadcast is real; the receipt wait is forced to fail regardless of + # real chain confirmation speed. + facilitator_signer.force_pending = True + + first = facilitator_scheme.settle(payload, requirements) + assert first.success is False, f"expected settlement_pending, got: {first}" + assert first.error_reason == ERR_SETTLEMENT_PENDING + assert first.transaction, ( + "expected a broadcast transaction hash on the settlement_pending result" + ) + first_tx_hash = first.transaction + assert facilitator_signer.write_contract_calls == 1 + + # Attempt 2: identical payload/requirements, receipt-waiting no longer forced to + # fail. Must reconcile against first_tx_hash (pending-store hit) rather than + # re-verifying and re-broadcasting. + facilitator_signer.force_pending = False + + second = facilitator_scheme.settle(payload, requirements) + assert second.success is True, ( + f"expected the reconciliation settle to succeed, got: {second}" + ) + assert second.transaction == first_tx_hash, ( + "reconciliation must reuse the already-broadcast transaction (no second " + f"broadcast): first={first_tx_hash} second={second.transaction}" + ) + assert second.payer.lower() == payer_address.lower() + # Still exactly one broadcast across both attempts. + assert facilitator_signer.write_contract_calls == 1 + + +class TestEvmResourceServerSettlementPendingRetry: + """Exercises the generic `x402ResourceServer(Sync).settle_payment` single-retry-on- + settlement_pending path (`settle_with_pending_retry` in server_base.py) against a + real broadcast, mirroring `TestEVMIntegrationV2_ResourceServerSettlementPendingRetry` + in go/test/integration/evm_test.go. + """ + + def test_retry_reconciles_without_a_second_broadcast(self) -> None: + """While `force_pending` is True for the whole call, both the initial attempt and + the SDK's automatic single retry are forced to fail their receipt wait, so both + are expected to observe settlement_pending. The key assertion is that the retry's + reported transaction hash is identical to the first attempt's, proving the + resource-server retry drove the mechanism's pending-cache fast path (reconciling + against the one broadcast transaction) rather than causing a second on-chain + broadcast. + + WARNING: This spends real Base Sepolia USDC. + """ + real_facilitator_signer = FacilitatorWeb3Signer( + private_key=FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingReceiptSigner(real_facilitator_signer) + facilitator_scheme = ExactEvmFacilitatorScheme( + facilitator_signer, + ExactEvmSchemeConfig(), # type: ignore[arg-type] + ) + + facilitator_client = SingleSchemeFacilitatorClientSync( + SCHEME_EXACT, NETWORK, facilitator_scheme + ) + server = x402ResourceServerSync(facilitator_client) + server.register(NETWORK, ExactEvmServerScheme()) + server.initialize() + + payload, requirements, payer_address = _build_payment_payload() + accepted = server.find_matching_requirements([requirements], payload) + assert accepted is not None + + facilitator_signer.force_pending = True + + first_attempt = server.settle_payment(payload, accepted) + assert first_attempt.success is False, ( + f"expected the resource server's (retried) settle to still return " + f"settlement_pending while receipt-waiting is forced to fail, got: {first_attempt}" + ) + assert first_attempt.error_reason == ERR_SETTLEMENT_PENDING + assert first_attempt.transaction, ( + "expected a broadcast transaction hash after the retried settlement_pending" + ) + first_attempt_tx_hash = first_attempt.transaction + # Exactly one broadcast across the initial attempt AND its automatic retry. + assert facilitator_signer.write_contract_calls == 1 + + # Reconcile with receipt-waiting no longer forced to fail, directly against the + # resource server (its facilitator client shares the same in-process + # mechanism/pending-store instance) to confirm exactly one transaction was ever + # broadcast across every attempt so far. + facilitator_signer.force_pending = False + + final = server.settle_payment(payload, accepted) + assert final.success is True, f"expected final reconciliation to succeed, got: {final}" + assert final.transaction == first_attempt_tx_hash, ( + "resource-server retry must not cause a second broadcast: " + f"first-attempt tx={first_attempt_tx_hash} final tx={final.transaction}" + ) + assert final.payer.lower() == payer_address.lower() + assert facilitator_signer.write_contract_calls == 1 diff --git a/python/x402/tests/integrations/test_svm_settlement_pending.py b/python/x402/tests/integrations/test_svm_settlement_pending.py new file mode 100644 index 0000000000..16496fb40d --- /dev/null +++ b/python/x402/tests/integrations/test_svm_settlement_pending.py @@ -0,0 +1,254 @@ +"""SVM integration tests for the settlement-pending auto-recovery mechanism. + +Mirrors the Go integration tests `TestSVMIntegrationV2_SettlementPendingReconciliation` +and `TestSVMIntegrationV2_ResourceServerSettlementPendingRetry` +(go/test/integration/svm_test.go), which are themselves the reference for this pattern — +see also `test_evm_settlement_pending.py` for the EVM equivalent this file mirrors. + +Uses `ForcedPendingConfirmSigner`, a decorator around the real `FacilitatorKeypairSigner` +that forces just `confirm_transaction` to fail on demand via a mutable flag, while every +other signer method (`sign_transaction`, `send_transaction`, `simulate_transaction`, +`get_addresses`) delegates unmodified to the real signer. This means every broadcast in +these tests is a real Solana devnet transaction, but the confirmation wait is +deterministically forceable — avoiding any dependency on real chain confirmation speed. + +Required environment variables: +- SVM_CLIENT_PRIVATE_KEY: Base58 private key for the client (payer). +- SVM_FACILITATOR_PRIVATE_KEY: Base58 private key for the facilitator (fee payer). +- SVM_FACILITATOR_ADDRESS: The facilitator's fee-payer address. +- SVM_RESOURCE_SERVER_ADDRESS: Recipient address for the settled payment. + +These must be funded accounts on Solana Devnet with SOL and USDC. + +WARNING: Every test in this file makes a REAL on-chain transaction. +""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest +from solders.keypair import Keypair + +from x402 import x402ClientSync, x402ResourceServerSync +from x402.mechanisms.svm import ( + SCHEME_EXACT, + SOLANA_DEVNET_CAIP2, + USDC_DEVNET_ADDRESS, + KeypairSigner, +) +from x402.mechanisms.svm.exact import ( + ExactSvmClientScheme, + ExactSvmFacilitatorScheme, + ExactSvmServerScheme, +) +from x402.mechanisms.svm.signers import FacilitatorKeypairSigner +from x402.schemas import PaymentPayload, PaymentRequired, PaymentRequirements, ResourceInfo + +from ._settlement_pending_helpers import SingleSchemeFacilitatorClientSync + +CLIENT_PRIVATE_KEY = os.environ.get("SVM_CLIENT_PRIVATE_KEY") +FACILITATOR_PRIVATE_KEY = os.environ.get("SVM_FACILITATOR_PRIVATE_KEY") +FACILITATOR_ADDRESS = os.environ.get("SVM_FACILITATOR_ADDRESS") +RESOURCE_SERVER_ADDRESS = os.environ.get("SVM_RESOURCE_SERVER_ADDRESS") + +RPC_URL = os.environ.get("SVM_RPC_URL") +NETWORK = SOLANA_DEVNET_CAIP2 + +pytestmark = pytest.mark.skipif( + not CLIENT_PRIVATE_KEY + or not FACILITATOR_PRIVATE_KEY + or not FACILITATOR_ADDRESS + or not RESOURCE_SERVER_ADDRESS, + reason=( + "SVM_CLIENT_PRIVATE_KEY, SVM_FACILITATOR_PRIVATE_KEY, SVM_FACILITATOR_ADDRESS, and " + "SVM_RESOURCE_SERVER_ADDRESS environment variables required for settlement_pending " + "integration tests" + ), +) + + +class ForcedPendingConfirmSigner: + """Wraps a real `FacilitatorSvmSigner` and, while `force_pending` is True, makes + `confirm_transaction` fail immediately instead of delegating to the real + (network-speed-dependent) confirmation polling. Every other method + (sign_transaction, send_transaction, simulate_transaction, get_addresses) delegates + unmodified to the wrapped signer via `__getattr__`, so every broadcast is always + real. + """ + + def __init__(self, wrapped: FacilitatorKeypairSigner) -> None: + self._wrapped = wrapped + self.force_pending = False + self.send_transaction_calls = 0 + + def confirm_transaction(self, signature: str, network: str) -> None: + if self.force_pending: + raise TimeoutError( + "forced confirmation failure for settlement_pending integration test" + ) + return self._wrapped.confirm_transaction(signature, network) + + def send_transaction(self, tx_base64: str, network: str) -> str: + self.send_transaction_calls += 1 + return self._wrapped.send_transaction(tx_base64, network) + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + +def _build_requirements(pay_to: str, amount: str, fee_payer: str) -> PaymentRequirements: + return PaymentRequirements( + scheme=SCHEME_EXACT, + network=NETWORK, + asset=USDC_DEVNET_ADDRESS, + amount=amount, + pay_to=pay_to, + max_timeout_seconds=3600, + extra={"feePayer": fee_payer}, + ) + + +def _build_payment_payload() -> tuple[PaymentPayload, PaymentRequirements, str]: + """Create a real, signed SVM exact payment payload shared by the tests below.""" + client_keypair = Keypair.from_base58_string(CLIENT_PRIVATE_KEY) + client_signer = KeypairSigner(client_keypair) + client = x402ClientSync().register( + NETWORK, ExactSvmClientScheme(client_signer, rpc_url=RPC_URL) + ) + client.set_spend_controls(False) + + requirements = _build_requirements(RESOURCE_SERVER_ADDRESS, "1000", FACILITATOR_ADDRESS) + resource = ResourceInfo( + url="https://api.example.com/premium", + description="Premium API Access", + mime_type="application/json", + ) + payment_required = PaymentRequired(x402_version=2, resource=resource, accepts=[requirements]) + payload = client.create_payment_payload(payment_required) + return payload, requirements, client_signer.address + + +class TestSvmSettlementPendingReconciliation: + """Exercises the settlement-pending mechanism layer directly against a real + on-chain SVM exact settlement, mirroring + `TestSVMIntegrationV2_SettlementPendingReconciliation` in go/test/integration/svm_test.go. + """ + + def test_reconciles_against_the_same_broadcast_signature(self) -> None: + """The first Settle call broadcasts for real but is forced to fail + `ConfirmTransaction`, producing a settlement_pending SettleResponse with the + broadcast signature attached and a PendingSettlementStore entry populated + (keyed by the transaction's message hash). A second Settle call with the + identical payload, now with confirmation no longer forced to fail, must hit the + pending-store fast path (skip verify/re-send) and reconcile against that + already-broadcast signature, returning success with the SAME signature as the + first attempt — proving no second transaction was ever broadcast. + + WARNING: This spends real Solana Devnet USDC. + """ + real_facilitator_signer = FacilitatorKeypairSigner.from_base58( + FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingConfirmSigner(real_facilitator_signer) + facilitator_scheme = ExactSvmFacilitatorScheme(facilitator_signer) # type: ignore[arg-type] + + payload, requirements, payer_address = _build_payment_payload() + + # Attempt 1: broadcast is real; confirmation is forced to fail regardless of + # real devnet confirmation speed. + facilitator_signer.force_pending = True + + first = facilitator_scheme.settle(payload, requirements) + assert first.success is False, f"expected settlement_pending, got: {first}" + assert first.error_reason == "settlement_pending" + assert first.transaction, ( + "expected a broadcast transaction signature on the settlement_pending result" + ) + first_signature = first.transaction + assert facilitator_signer.send_transaction_calls == 1 + + # Attempt 2: identical payload/requirements, confirmation no longer forced to + # fail. Must reconcile against first_signature (pending-store hit) rather than + # re-verifying and re-sending. + facilitator_signer.force_pending = False + + second = facilitator_scheme.settle(payload, requirements) + assert second.success is True, ( + f"expected the reconciliation settle to succeed, got: {second}" + ) + assert second.transaction == first_signature, ( + "reconciliation must reuse the already-broadcast transaction (no second " + f"broadcast): first={first_signature} second={second.transaction}" + ) + assert second.payer == payer_address + # Still exactly one broadcast across both attempts. + assert facilitator_signer.send_transaction_calls == 1 + + +class TestSvmResourceServerSettlementPendingRetry: + """Exercises the generic `x402ResourceServer(Sync).settle_payment` single-retry-on- + settlement_pending path (`settle_with_pending_retry` in server_base.py) against a + real SVM broadcast, mirroring + `TestSVMIntegrationV2_ResourceServerSettlementPendingRetry` in + go/test/integration/svm_test.go. + """ + + def test_retry_reconciles_without_a_second_broadcast(self) -> None: + """While `force_pending` is True for the whole call, both the initial attempt + and the SDK's automatic single retry are forced to fail confirmation, so both + are expected to observe settlement_pending. The key assertion is that the + retry's reported transaction signature is identical to the first attempt's, + proving the resource-server retry drove the mechanism's pending-cache fast path + (reconciling against the one broadcast transaction) rather than causing a second + on-chain broadcast. + + WARNING: This spends real Solana Devnet USDC. + """ + real_facilitator_signer = FacilitatorKeypairSigner.from_base58( + FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL + ) + facilitator_signer = ForcedPendingConfirmSigner(real_facilitator_signer) + facilitator_scheme = ExactSvmFacilitatorScheme(facilitator_signer) # type: ignore[arg-type] + + facilitator_client = SingleSchemeFacilitatorClientSync( + SCHEME_EXACT, NETWORK, facilitator_scheme + ) + server = x402ResourceServerSync(facilitator_client) + server.register(NETWORK, ExactSvmServerScheme()) + server.initialize() + + payload, requirements, payer_address = _build_payment_payload() + accepted = server.find_matching_requirements([requirements], payload) + assert accepted is not None + + facilitator_signer.force_pending = True + + first_attempt = server.settle_payment(payload, accepted) + assert first_attempt.success is False, ( + f"expected the resource server's (retried) settle to still return " + f"settlement_pending while confirmation is forced to fail, got: {first_attempt}" + ) + assert first_attempt.error_reason == "settlement_pending" + assert first_attempt.transaction, ( + "expected a broadcast transaction signature after the retried settlement_pending" + ) + first_attempt_signature = first_attempt.transaction + # Exactly one broadcast across the initial attempt AND its automatic retry. + assert facilitator_signer.send_transaction_calls == 1 + + # Reconcile with confirmation no longer forced to fail, directly against the + # resource server (its facilitator client shares the same in-process + # mechanism/pending-store instance) to confirm exactly one transaction was ever + # broadcast across every attempt so far. + facilitator_signer.force_pending = False + + final = server.settle_payment(payload, accepted) + assert final.success is True, f"expected final reconciliation to succeed, got: {final}" + assert final.transaction == first_attempt_signature, ( + "resource-server retry must not cause a second broadcast: " + f"first-attempt tx={first_attempt_signature} final tx={final.transaction}" + ) + assert final.payer == payer_address + assert facilitator_signer.send_transaction_calls == 1 diff --git a/python/x402/tests/unit/core/test_pending_settlement_store.py b/python/x402/tests/unit/core/test_pending_settlement_store.py new file mode 100644 index 0000000000..66da272650 --- /dev/null +++ b/python/x402/tests/unit/core/test_pending_settlement_store.py @@ -0,0 +1,143 @@ +"""Unit tests for PendingSettlementStore and InMemoryPendingSettlementStore.""" + +from __future__ import annotations + +from x402 import InMemoryPendingSettlementStore, PendingSettlementStore +from x402.pending_settlement_store import PENDING_SETTLEMENT_TTL_SECONDS + + +class TestInMemoryPendingSettlementStore: + def test_get_returns_none_on_miss(self): + store = InMemoryPendingSettlementStore() + + assert store.get("missing-key") is None + + def test_set_then_get_returns_stored_value(self): + store = InMemoryPendingSettlementStore() + + store.set("key-1", "0xabc") + + assert store.get("key-1") == "0xabc" + + def test_set_overwrites_prior_value_for_same_key(self): + store = InMemoryPendingSettlementStore() + + store.set("key-1", "0xabc") + store.set("key-1", "0xdef") + + assert store.get("key-1") == "0xdef" + + def test_delete_removes_entry(self): + store = InMemoryPendingSettlementStore() + store.set("key-1", "0xabc") + + store.delete("key-1") + + assert store.get("key-1") is None + + def test_delete_on_missing_key_is_a_no_op(self): + store = InMemoryPendingSettlementStore() + + store.delete("never-existed") # must not raise + + assert store.get("never-existed") is None + + def test_distinct_keys_do_not_collide(self): + store = InMemoryPendingSettlementStore() + + store.set("key-a", "0xaaa") + store.set("key-b", "0xbbb") + + assert store.get("key-a") == "0xaaa" + assert store.get("key-b") == "0xbbb" + + def test_entries_older_than_ttl_are_pruned_on_get(self): + store = InMemoryPendingSettlementStore() + store.set("expired", "0xabc") + + tx_hash, stored_at = store.entries["expired"] + store.entries["expired"] = (tx_hash, stored_at - PENDING_SETTLEMENT_TTL_SECONDS - 1) + + assert store.get("expired") is None + + def test_fresh_entries_survive_pruning(self): + store = InMemoryPendingSettlementStore() + store.set("expired", "0xabc") + store.set("fresh", "0xdef") + + tx_hash, stored_at = store.entries["expired"] + store.entries["expired"] = (tx_hash, stored_at - PENDING_SETTLEMENT_TTL_SECONDS - 1) + + # Triggers a prune pass as a side effect of get(). + assert store.get("expired") is None + assert store.get("fresh") == "0xdef" + + def test_prune_rescans_after_a_set_refreshes_an_existing_key(self): + """A set() on an existing key must refresh its timestamp for pruning purposes, + even though dict insertion order keeps the key in its original position.""" + store = InMemoryPendingSettlementStore() + store.set("key-a", "0xaaa") + store.set("key-b", "0xbbb") + + # Refresh key-a's timestamp well after key-b was inserted. + store.set("key-a", "0xaaa2") + + # Backdate key-b (inserted first, but never refreshed) past the TTL. + tx_hash, stored_at = store.entries["key-b"] + store.entries["key-b"] = (tx_hash, stored_at - PENDING_SETTLEMENT_TTL_SECONDS - 1) + + # key-a must survive pruning despite being first in insertion order, because its + # timestamp was refreshed after key-b's. + assert store.get("key-a") == "0xaaa2" + assert store.get("key-b") is None + + +class _StubPendingSettlementStore: + """Minimal PendingSettlementStore implementation used to prove mechanism code only + depends on the Protocol, never the concrete InMemoryPendingSettlementStore type.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + self.calls.append(("get", key)) + return self._data.get(key) + + def set(self, key: str, tx_hash: str) -> None: + self.calls.append(("set", key, tx_hash)) + self._data[key] = tx_hash + + def delete(self, key: str) -> None: + self.calls.append(("delete", key)) + self._data.pop(key, None) + + +class TestPendingSettlementStoreProtocol: + def test_in_memory_store_satisfies_protocol(self): + store: PendingSettlementStore = InMemoryPendingSettlementStore() + + assert isinstance(store, PendingSettlementStore) + + def test_stub_implementation_satisfies_protocol(self): + store: PendingSettlementStore = _StubPendingSettlementStore() + + assert isinstance(store, PendingSettlementStore) + + def test_stub_implementation_is_usable_wherever_the_protocol_is_expected(self): + def _round_trip(store: PendingSettlementStore) -> str | None: + store.set("key", "0x123") + value = store.get("key") + store.delete("key") + return value + + stub = _StubPendingSettlementStore() + + assert _round_trip(stub) == "0x123" + assert stub.get("key") is None + assert stub.calls == [ + ("set", "key", "0x123"), + ("get", "key"), + ("delete", "key"), + ("get", "key"), + ] diff --git a/python/x402/tests/unit/core/test_settle_pending_retry.py b/python/x402/tests/unit/core/test_settle_pending_retry.py new file mode 100644 index 0000000000..aad8dd9ea0 --- /dev/null +++ b/python/x402/tests/unit/core/test_settle_pending_retry.py @@ -0,0 +1,422 @@ +"""Unit tests for the resource server's single automatic settle retry on settlement_pending. + +Covers `is_retryable_settlement_pending`, `settle_with_pending_retry` (sync), +`settle_with_pending_retry_async`, and end-to-end `x402ResourceServer(Sync).settle_payment` +routing through the retry. +""" + +from __future__ import annotations + +import asyncio + +from x402 import x402ResourceServer, x402ResourceServerSync +from x402.schemas import ( + PaymentPayload, + PaymentRequirements, + ResourceInfo, + SettleResponse, + SupportedKind, + SupportedResponse, + VerifyResponse, +) +from x402.server_base import ( + is_retryable_settlement_pending, + settle_with_pending_retry, + settle_with_pending_retry_async, +) + +NETWORK = "eip155:8453" + + +def _pending_response(transaction: str = "0xpending") -> SettleResponse: + return SettleResponse( + success=False, + error_reason="settlement_pending", + transaction=transaction, + network=NETWORK, + ) + + +def _success_response(transaction: str = "0xsuccess") -> SettleResponse: + return SettleResponse(success=True, transaction=transaction, network=NETWORK) + + +def _terminal_failure_response(reason: str = "invalid_signature") -> SettleResponse: + return SettleResponse(success=False, error_reason=reason, transaction="", network=NETWORK) + + +class TestSettlementPendingReasonUnification: + """Every mechanism must report the exact same `settlement_pending` reason string as + the core `ERR_SETTLEMENT_PENDING` constant this module's retry logic checks against + (`is_retryable_settlement_pending`). A mismatched mechanism-specific constant would + silently disable the resource server's automatic single retry for that mechanism. + """ + + def test_evm_constant_matches_core_constant(self): + from x402.mechanisms.evm.constants import ERR_SETTLEMENT_PENDING as EVM_REASON + from x402.pending_settlement_store import ERR_SETTLEMENT_PENDING as CORE_REASON + + assert EVM_REASON == CORE_REASON == "settlement_pending" + + def test_svm_constant_matches_core_constant(self): + from x402.mechanisms.svm.constants import ERR_SETTLEMENT_PENDING as SVM_REASON + from x402.pending_settlement_store import ERR_SETTLEMENT_PENDING as CORE_REASON + + assert SVM_REASON == CORE_REASON == "settlement_pending" + + def test_svm_confirm_timeout_result_is_recognized_as_retryable(self): + """Pins the actual behavior the constant unification exists for: an SVM + confirm-timeout SettleResponse (using the SVM-scoped constant) must be recognized + by the core, mechanism-agnostic retry check.""" + from x402.mechanisms.svm.constants import ERR_SETTLEMENT_PENDING as SVM_REASON + + svm_confirm_timeout_result = SettleResponse( + success=False, + error_reason=SVM_REASON, + transaction="mockSignature123", + network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + ) + + assert is_retryable_settlement_pending(svm_confirm_timeout_result) is True + + +class TestIsRetryableSettlementPending: + def test_true_for_settlement_pending_with_transaction(self): + assert is_retryable_settlement_pending(_pending_response()) is True + + def test_false_for_success(self): + assert is_retryable_settlement_pending(_success_response()) is False + + def test_false_for_other_failure_reason(self): + assert is_retryable_settlement_pending(_terminal_failure_response()) is False + + def test_false_for_settlement_pending_without_transaction(self): + result = SettleResponse( + success=False, error_reason="settlement_pending", transaction="", network=NETWORK + ) + assert is_retryable_settlement_pending(result) is False + + def test_false_for_none_error_reason(self): + result = SettleResponse( + success=False, error_reason=None, transaction="0xabc", network=NETWORK + ) + assert is_retryable_settlement_pending(result) is False + + def test_false_for_empty_string_error_reason(self): + result = SettleResponse( + success=False, error_reason="", transaction="0xabc", network=NETWORK + ) + assert is_retryable_settlement_pending(result) is False + + +class _StubSyncFacilitatorClient: + def __init__(self, responses: list[SettleResponse]): + self._responses = list(responses) + self.calls: list[tuple] = [] + + def settle(self, payload, requirements) -> SettleResponse: + self.calls.append((payload, requirements)) + return self._responses.pop(0) + + +class _StubAsyncFacilitatorClient: + def __init__(self, responses: list[SettleResponse]): + self._responses = list(responses) + self.calls: list[tuple] = [] + + async def settle(self, payload, requirements) -> SettleResponse: + self.calls.append((payload, requirements)) + return self._responses.pop(0) + + +class TestSettleWithPendingRetrySync: + def test_no_retry_on_success(self): + client = _StubSyncFacilitatorClient([_success_response("0x1")]) + + result = settle_with_pending_retry(client, "payload", "requirements") + + assert result.success is True + assert len(client.calls) == 1 + + def test_no_retry_on_terminal_failure(self): + client = _StubSyncFacilitatorClient([_terminal_failure_response()]) + + result = settle_with_pending_retry(client, "payload", "requirements") + + assert result.success is False + assert result.error_reason == "invalid_signature" + assert len(client.calls) == 1 + + def test_single_retry_on_settlement_pending_then_success(self): + client = _StubSyncFacilitatorClient( + [_pending_response("0xpending"), _success_response("0xconfirmed")] + ) + + result = settle_with_pending_retry(client, "payload", "requirements") + + assert result.success is True + assert result.transaction == "0xconfirmed" + assert len(client.calls) == 2 + + def test_retry_uses_identical_payload_and_requirements(self): + client = _StubSyncFacilitatorClient([_pending_response(), _success_response()]) + payload, requirements = object(), object() + + settle_with_pending_retry(client, payload, requirements) + + assert client.calls == [(payload, requirements), (payload, requirements)] + + def test_capped_at_one_retry_when_second_attempt_is_also_pending(self): + client = _StubSyncFacilitatorClient( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + + result = settle_with_pending_retry(client, "payload", "requirements") + + assert result.success is False + assert result.error_reason == "settlement_pending" + assert result.transaction == "0xsecond" + assert len(client.calls) == 2 # never a third call + + +class TestSettleWithPendingRetryAsync: + def test_no_retry_on_success(self): + client = _StubAsyncFacilitatorClient([_success_response("0x1")]) + + result = asyncio.run(settle_with_pending_retry_async(client, "payload", "requirements")) + + assert result.success is True + assert len(client.calls) == 1 + + def test_single_retry_on_settlement_pending_then_success(self): + client = _StubAsyncFacilitatorClient( + [_pending_response("0xpending"), _success_response("0xconfirmed")] + ) + + result = asyncio.run(settle_with_pending_retry_async(client, "payload", "requirements")) + + assert result.success is True + assert result.transaction == "0xconfirmed" + assert len(client.calls) == 2 + + def test_capped_at_one_retry(self): + client = _StubAsyncFacilitatorClient( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + + result = asyncio.run(settle_with_pending_retry_async(client, "payload", "requirements")) + + assert result.success is False + assert result.transaction == "0xsecond" + assert len(client.calls) == 2 + + +# ============================================================================= +# End-to-end x402ResourceServer(Sync).settle_payment retry routing +# ============================================================================= + + +class _RecordingSchemeServer: + scheme = "mock" + + def get_payment_requirements(self, config): # pragma: no cover - not exercised + raise NotImplementedError + + +def _requirements() -> PaymentRequirements: + return PaymentRequirements( + scheme="mock", + network=NETWORK, + asset="0x0000000000000000000000000000000000000001", + amount="100", + pay_to="0x0000000000000000000000000000000000000002", + max_timeout_seconds=60, + extra={}, + ) + + +def _payload() -> PaymentPayload: + return PaymentPayload( + x402_version=2, + resource=ResourceInfo(url="http://example.com", description="", mime_type="text/plain"), + accepted=_requirements(), + payload={}, + ) + + +class _E2ESyncFacilitatorClient: + def __init__(self, responses: list[SettleResponse]): + self._responses = list(responses) + self.settle_calls = 0 + + def get_supported(self) -> SupportedResponse: + return SupportedResponse( + kinds=[SupportedKind(x402_version=2, scheme="mock", network=NETWORK)], + extensions=[], + signers={}, + ) + + def verify(self, payload, requirements) -> VerifyResponse: + return VerifyResponse(is_valid=True) + + def settle(self, payload, requirements) -> SettleResponse: + self.settle_calls += 1 + return self._responses.pop(0) + + +class _E2EAsyncFacilitatorClient: + def __init__(self, responses: list[SettleResponse]): + self._responses = list(responses) + self.settle_calls = 0 + + def get_supported(self) -> SupportedResponse: + return SupportedResponse( + kinds=[SupportedKind(x402_version=2, scheme="mock", network=NETWORK)], + extensions=[], + signers={}, + ) + + async def verify(self, payload, requirements) -> VerifyResponse: + return VerifyResponse(is_valid=True) + + async def settle(self, payload, requirements) -> SettleResponse: + self.settle_calls += 1 + return self._responses.pop(0) + + +class TestResourceServerSyncSettleRetry: + def _make_server(self, responses: list[SettleResponse]) -> tuple: + client = _E2ESyncFacilitatorClient(responses) + server = x402ResourceServerSync(client) + server.register(NETWORK, _RecordingSchemeServer()) + server.initialize() + return server, client + + def test_single_retry_on_settlement_pending_resolves_to_success(self): + server, client = self._make_server( + [_pending_response("0xpending"), _success_response("0xconfirmed")] + ) + + result = server.settle_payment(_payload(), _requirements()) + + assert result.success is True + assert result.transaction == "0xconfirmed" + assert client.settle_calls == 2 + + def test_no_retry_on_success(self): + server, client = self._make_server([_success_response("0x1")]) + + result = server.settle_payment(_payload(), _requirements()) + + assert result.success is True + assert client.settle_calls == 1 + + def test_no_retry_on_non_pending_failure(self): + server, client = self._make_server([_terminal_failure_response("balance_too_low")]) + + result = server.settle_payment(_payload(), _requirements()) + + assert result.success is False + assert result.error_reason == "balance_too_low" + assert client.settle_calls == 1 + + def test_capped_at_one_retry_on_second_settlement_pending(self): + server, client = self._make_server( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + + result = server.settle_payment(_payload(), _requirements()) + + assert result.success is False + assert result.error_reason == "settlement_pending" + assert result.transaction == "0xsecond" + assert client.settle_calls == 2 + + def test_recovery_hook_still_runs_after_a_non_recovered_retry(self): + """A final settlement_pending (after the capped retry) is still success=False, so + it must route through on_settle_failure like any other failure — and a hook that + recovers must short-circuit to its recovered result.""" + from x402.schemas import RecoveredSettleResult + + server, client = self._make_server( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + recovered = _success_response("0xrecovered") + server.on_settle_failure(lambda ctx: RecoveredSettleResult(result=recovered)) + + result = server.settle_payment(_payload(), _requirements()) + + assert result.success is True + assert result.transaction == "0xrecovered" + assert client.settle_calls == 2 + + +class TestResourceServerAsyncSettleRetry: + def _make_server(self, responses: list[SettleResponse]) -> tuple: + client = _E2EAsyncFacilitatorClient(responses) + server = x402ResourceServer(client) + server.register(NETWORK, _RecordingSchemeServer()) + server.initialize() + return server, client + + def test_single_retry_on_settlement_pending_resolves_to_success(self): + server, client = self._make_server( + [_pending_response("0xpending"), _success_response("0xconfirmed")] + ) + + result = asyncio.run(server.settle_payment(_payload(), _requirements())) + + assert result.success is True + assert result.transaction == "0xconfirmed" + assert client.settle_calls == 2 + + def test_no_retry_on_success(self): + server, client = self._make_server([_success_response("0x1")]) + + result = asyncio.run(server.settle_payment(_payload(), _requirements())) + + assert result.success is True + assert client.settle_calls == 1 + + def test_no_retry_on_non_pending_failure(self): + server, client = self._make_server([_terminal_failure_response("balance_too_low")]) + + result = asyncio.run(server.settle_payment(_payload(), _requirements())) + + assert result.success is False + assert result.error_reason == "balance_too_low" + assert client.settle_calls == 1 + + def test_capped_at_one_retry_on_second_settlement_pending(self): + server, client = self._make_server( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + + result = asyncio.run(server.settle_payment(_payload(), _requirements())) + + assert result.success is False + assert result.error_reason == "settlement_pending" + assert result.transaction == "0xsecond" + assert client.settle_calls == 2 + + def test_recovery_hook_still_runs_after_a_non_recovered_retry(self): + """Async counterpart of the sync test above: a final settlement_pending (after + the capped retry) must still route through an async on_settle_failure hook, which + must be able to recover it via an awaited RecoveredSettleResult.""" + from x402.schemas import RecoveredSettleResult + + server, client = self._make_server( + [_pending_response("0xfirst"), _pending_response("0xsecond")] + ) + recovered = _success_response("0xrecovered") + + async def _recover(ctx): + return RecoveredSettleResult(result=recovered) + + server.on_settle_failure(_recover) + + result = asyncio.run(server.settle_payment(_payload(), _requirements())) + + assert result.success is True + assert result.transaction == "0xrecovered" + assert client.settle_calls == 2 diff --git a/python/x402/tests/unit/http/test_bazaar_extension_validation.py b/python/x402/tests/unit/http/test_bazaar_extension_validation.py index 4da02a9629..144dce5db6 100644 --- a/python/x402/tests/unit/http/test_bazaar_extension_validation.py +++ b/python/x402/tests/unit/http/test_bazaar_extension_validation.py @@ -542,3 +542,105 @@ def test_validate_warns_on_spec_violation(self): bazaar_warnings = [w for w in caught if "bazaar" in str(w.message).lower()] assert len(bazaar_warnings) == 1 assert "invalid bazaar extension" in str(bazaar_warnings[0].message).lower() + + def test_validate_no_warning_on_declare_discovery_extension_get(self): + """Pre-enrichment declare_discovery_extension on a GET route should not warn.""" + from x402.extensions.bazaar import OutputConfig, declare_discovery_extension + from x402.http.middleware._bazaar_utils import validate_bazaar_extensions + + routes = { + "GET /weather": RouteConfig( + accepts=[_make_payment_option()], + extensions={ + **declare_discovery_extension( + input={"city": "San Francisco"}, + input_schema={ + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + output=OutputConfig( + example={"weather": "sunny", "temperature": 70}, + schema={ + "properties": { + "weather": {"type": "string"}, + "temperature": {"type": "number"}, + }, + "required": ["weather", "temperature"], + }, + ), + ) + }, + ) + } + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + validate_bazaar_extensions(routes) + + bazaar_warnings = [w for w in caught if "bazaar" in str(w.message).lower()] + assert len(bazaar_warnings) == 0 + + def test_validate_no_warning_on_post_body_without_route_verb(self): + """Body extensions without a route verb should infer POST and not warn.""" + from x402.extensions.bazaar import OutputConfig, declare_discovery_extension + from x402.http.middleware._bazaar_utils import validate_bazaar_extensions + + routes = { + "*": RouteConfig( + accepts=[_make_payment_option()], + extensions={ + **declare_discovery_extension( + input={"name": "John"}, + input_schema={ + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + body_type="json", + output=OutputConfig(example={"success": True}), + ) + }, + ) + } + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + validate_bazaar_extensions(routes) + + bazaar_warnings = [w for w in caught if "bazaar" in str(w.message).lower()] + assert len(bazaar_warnings) == 0 + + def test_with_synthetic_method_leaves_existing_method_unchanged(self): + from x402.http.middleware._bazaar_utils import _with_synthetic_method + + ext = { + "info": {"input": {"type": "http", "method": "DELETE"}}, + "schema": {}, + } + result = _with_synthetic_method(ext, "GET /api") + assert result["info"]["input"]["method"] == "DELETE" + + def test_with_synthetic_method_injects_from_route_pattern(self): + from x402.http.middleware._bazaar_utils import _with_synthetic_method + + ext = { + "info": {"input": {"type": "http", "queryParams": {"city": "sf"}}}, + "schema": {}, + } + result = _with_synthetic_method(ext, "GET /weather") + assert result["info"]["input"]["method"] == "GET" + + def test_with_synthetic_method_infers_post_from_body(self): + from x402.http.middleware._bazaar_utils import _with_synthetic_method + + ext = { + "info": { + "input": { + "type": "http", + "bodyType": "json", + "body": {"name": "John"}, + } + }, + "schema": {}, + } + result = _with_synthetic_method(ext, "*") + assert result["info"]["input"]["method"] == "POST" diff --git a/python/x402/tests/unit/http/test_extension_responses.py b/python/x402/tests/unit/http/test_extension_responses.py new file mode 100644 index 0000000000..0336b8073c --- /dev/null +++ b/python/x402/tests/unit/http/test_extension_responses.py @@ -0,0 +1,57 @@ +"""Unit tests for extension_responses HTTP sidechannel utilities.""" + +from __future__ import annotations + +import base64 +import json +from unittest.mock import MagicMock + +from x402.http.extension_responses import ( + EXTENSION_RESPONSES_HEADER, + extract_extension_responses_header, +) + + +def _response_with_header(value: str | None) -> MagicMock: + response = MagicMock() + headers = {} + if value is not None: + headers[EXTENSION_RESPONSES_HEADER] = value + response.headers = headers + return response + + +class TestExtractExtensionResponsesHeader: + def test_decodes_multi_key_envelope(self) -> None: + payload = { + "bazaar": {"status": "processing"}, + "builder_code": {"status": "accepted"}, + } + encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") + response = _response_with_header(encoded) + + assert extract_extension_responses_header(response) == payload + + def test_returns_none_when_header_missing(self) -> None: + response = _response_with_header(None) + + assert extract_extension_responses_header(response) is None + + def test_returns_none_for_malformed_header(self) -> None: + response = _response_with_header("not-valid-base64!!!") + + assert extract_extension_responses_header(response) is None + + def test_returns_none_for_non_object_json(self) -> None: + encoded = base64.b64encode(b'"string-value"').decode("utf-8") + response = _response_with_header(encoded) + + assert extract_extension_responses_header(response) is None + + def test_accepts_lowercase_header_name(self) -> None: + payload = {"bazaar": {"status": "success"}} + encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") + response = MagicMock() + response.headers = {"extension-responses": encoded} + + assert extract_extension_responses_header(response) == payload diff --git a/python/x402/tests/unit/http/test_facilitator_client.py b/python/x402/tests/unit/http/test_facilitator_client.py index 03d3ef5b58..2f275425a0 100644 --- a/python/x402/tests/unit/http/test_facilitator_client.py +++ b/python/x402/tests/unit/http/test_facilitator_client.py @@ -2,11 +2,13 @@ from __future__ import annotations +import base64 import json from unittest.mock import AsyncMock, MagicMock import pytest +from x402.http.extension_responses import EXTENSION_RESPONSES_HEADER from x402.http.facilitator_client import ( HTTPFacilitatorClient, HTTPFacilitatorClientSync, @@ -39,6 +41,105 @@ def make_v2_payload(signature: str = "0xmock") -> PaymentPayload: ) +def _encode_extension_responses(payload: dict) -> str: + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") + + +def _make_settle_response() -> dict: + return { + "success": True, + "transaction": "0xabc", + "network": "eip155:8453", + "payer": "0x1234567890123456789012345678901234567890", + } + + +@pytest.mark.asyncio +async def test_async_verify_sets_extension_responses_from_header(): + """Verify should populate extension_responses without touching extensions.""" + header_payload = {"bazaar": {"status": "processing"}} + response = MagicMock(status_code=200, text="ok") + response.json.return_value = {"isValid": True, "payer": "0xpayer"} + response.headers = {EXTENSION_RESPONSES_HEADER: _encode_extension_responses(header_payload)} + + http_client = MagicMock() + http_client.post = AsyncMock(return_value=response) + + client = HTTPFacilitatorClient( + FacilitatorConfig(url="https://facilitator.test", http_client=http_client) + ) + + result = await client.verify(make_v2_payload(), make_payment_requirements()) + + assert result.extension_responses == header_payload + assert result.extensions is None + + +@pytest.mark.asyncio +async def test_async_settle_keeps_body_extensions_independent_from_sidechannel(): + """Body extensions and header extension_responses must remain separate.""" + body_extensions = {"terms": {"info": {"format": "uri"}}} + header_payload = {"bazaar": {"status": "success"}} + response = MagicMock(status_code=200, text="ok") + response.json.return_value = { + **_make_settle_response(), + "extensions": body_extensions, + } + response.headers = {EXTENSION_RESPONSES_HEADER: _encode_extension_responses(header_payload)} + + http_client = MagicMock() + http_client.post = AsyncMock(return_value=response) + + client = HTTPFacilitatorClient( + FacilitatorConfig(url="https://facilitator.test", http_client=http_client) + ) + + result = await client.settle(make_v2_payload(), make_payment_requirements()) + + assert result.extensions == body_extensions + assert result.extension_responses == header_payload + + +@pytest.mark.asyncio +async def test_async_verify_ignores_malformed_extension_responses_header(): + response = MagicMock(status_code=200, text="ok") + response.json.return_value = {"isValid": True, "payer": "0xpayer"} + response.headers = {EXTENSION_RESPONSES_HEADER: "not-valid"} + + http_client = MagicMock() + http_client.post = AsyncMock(return_value=response) + + client = HTTPFacilitatorClient( + FacilitatorConfig(url="https://facilitator.test", http_client=http_client) + ) + + result = await client.verify(make_v2_payload(), make_payment_requirements()) + + assert result.extension_responses is None + + +def test_sync_settle_sets_extension_responses_from_header(): + header_payload = { + "bazaar": {"status": "processing"}, + "builder_code": {"status": "accepted"}, + } + response = MagicMock(status_code=200, text="ok") + response.json.return_value = _make_settle_response() + response.headers = {EXTENSION_RESPONSES_HEADER: _encode_extension_responses(header_payload)} + + http_client = MagicMock() + http_client.post.return_value = response + + client = HTTPFacilitatorClientSync( + FacilitatorConfig(url="https://facilitator.test", http_client=http_client) + ) + + result = client.settle(make_v2_payload(), make_payment_requirements()) + + assert result.extension_responses == header_payload + assert result.extensions is None + + @pytest.mark.asyncio async def test_async_verify_raises_facilitator_response_error_for_invalid_json(): """Async verify should surface invalid JSON as facilitator boundary error.""" diff --git a/python/x402/tests/unit/http/test_utils.py b/python/x402/tests/unit/http/test_utils.py index 06e7b45e0c..f059e36c72 100644 --- a/python/x402/tests/unit/http/test_utils.py +++ b/python/x402/tests/unit/http/test_utils.py @@ -275,6 +275,23 @@ def test_decode_failed_settle_response(self): assert decoded.success is False assert decoded.error_reason == "Insufficient funds" + def test_encode_excludes_extension_responses_sidechannel(self): + """PAYMENT-RESPONSE must not include facilitator extension_responses.""" + settle = SettleResponse( + success=True, + transaction="0xabc123", + network="eip155:8453", + payer="0x1234567890123456789012345678901234567890", + extension_responses={"bazaar": {"status": "processing"}}, + ) + encoded = encode_payment_response_header(settle) + decoded_json = safe_base64_decode(encoded) + data = json.loads(decoded_json) + + assert "extensionResponses" not in data + assert "extension_responses" not in data + assert "bazaar" not in data + class TestDetectPaymentRequiredVersion: """Tests for version detection.""" diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_deposit_settlement_pending.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_deposit_settlement_pending.py new file mode 100644 index 0000000000..2c7e3c7690 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_deposit_settlement_pending.py @@ -0,0 +1,328 @@ +"""Unit tests for the PendingSettlementStore fast path in `settle_deposit`. + +Mirrors the cache-miss/cache-hit shape already covered for the exact EIP-3009/Permit2 +and upto Permit2 settle paths (see `test_facilitator.py::TestEip3009PendingSettlementStore` +and `test_upto_facilitator.py::TestUptoPermit2PendingSettlementStore`), but for the +batch-settlement deposit path (`deposit.py::settle_deposit` / +`deposit.py::_reconcile_pending_deposit`). Prior to this file, the deposit settle path's +pending-store integration (both the cache-miss population and the cache-hit +reconciliation/skip-verify behavior) had no dedicated coverage. + +Heavy monkeypatching of `verify_deposit` / `_resolve_deposit_execution` / +`_resolve_deposit_transfer_method` / `read_channel_state` mirrors the pattern used by +`test_scheme.py::TestSettleReceiptWait` to isolate the pending-store bookkeeping from the +(separately tested) verify/execution-resolution logic. +""" + +from __future__ import annotations + +import pytest + +try: + from eth_utils import to_checksum_address # noqa: F401 (import guards evm extras) + + from x402.mechanisms.evm.batch_settlement.facilitator import deposit as deposit_mod + from x402.mechanisms.evm.batch_settlement.facilitator.deposit import ( + _deposit_settlement_cache_key, + settle_deposit, + ) + from x402.mechanisms.evm.batch_settlement.types import ( + ChannelConfig, + ChannelState, + DepositAuthorization, + DepositFields, + DepositPayload, + Erc3009Authorization, + VoucherFields, + ) + from x402.mechanisms.evm.constants import ERR_SETTLEMENT_PENDING, TX_STATUS_SUCCESS + from x402.pending_settlement_store import InMemoryPendingSettlementStore + from x402.schemas import PaymentPayload, PaymentRequirements, VerifyResponse +except ImportError: + pytest.skip("batch_settlement requires evm extras", allow_module_level=True) + + +NETWORK = "eip155:8453" +PAYER = "0x1111111111111111111111111111111111111111" +TOKEN = "0x5555555555555555555555555555555555555555" +CHANNEL_ID = "0x" + "ab" * 32 +SIGNATURE = "0x" + "33" * 65 +TX_HASH = "0x" + "cd" * 32 + + +def _channel_config() -> ChannelConfig: + return ChannelConfig( + payer=PAYER, + payer_authorizer=PAYER, + receiver="0x3333333333333333333333333333333333333333", + receiver_authorizer="0x4444444444444444444444444444444444444444", + token=TOKEN, + withdraw_delay=900, + salt="0x" + "00" * 32, + ) + + +def _deposit_payload(signature: str = SIGNATURE) -> DepositPayload: + p = DepositPayload() + p.channel_config = _channel_config() + p.voucher = VoucherFields( + channel_id=CHANNEL_ID, max_claimable_amount="1000", signature="0x" + "11" * 65 + ) + p.deposit = DepositFields( + amount="500", + authorization=DepositAuthorization( + erc3009_authorization=Erc3009Authorization( + valid_after="0", + valid_before="99999999999", + salt="0x" + "22" * 32, + signature=signature, + ) + ), + ) + return p + + +def _requirements() -> PaymentRequirements: + return PaymentRequirements( + scheme="batch-settlement", + network=NETWORK, + asset=TOKEN, + amount="500", + pay_to="0x3333333333333333333333333333333333333333", + max_timeout_seconds=60, + extra={}, + ) + + +def _dummy_payment() -> PaymentPayload: + return PaymentPayload(x402_version=2, payload={}, accepted=_requirements()) + + +def _valid_verify_response() -> VerifyResponse: + return VerifyResponse( + is_valid=True, + payer=PAYER, + extra={ + "balance": "0", + "totalClaimed": "0", + "withdrawRequestedAt": 0, + "refundNonce": "0", + }, + ) + + +def _channel_state() -> ChannelState: + return ChannelState(balance=500, total_claimed=0, withdraw_requested_at=0, refund_nonce=0) + + +class _FakeReceipt: + status = TX_STATUS_SUCCESS + + +class _Signer: + """Minimal signer stub covering only what the deposit settle path needs.""" + + def __init__(self, receipt_error: Exception | None = None, tx_hash: str = TX_HASH) -> None: + self.write_calls = 0 + self._receipt_error = receipt_error + self._tx_hash = tx_hash + + def write_contract(self, *args, **kwargs) -> str: + self.write_calls += 1 + return self._tx_hash + + def wait_for_transaction_receipt(self, tx_hash: str) -> _FakeReceipt: + if self._receipt_error is not None: + raise self._receipt_error + return _FakeReceipt() + + +@pytest.fixture(autouse=True) +def _patch_transfer_method(monkeypatch): + """Every fixture here uses the eip3009 transfer method (direct, no extension signer).""" + monkeypatch.setattr(deposit_mod, "_resolve_deposit_transfer_method", lambda *a, **k: "eip3009") + monkeypatch.setattr( + deposit_mod, "_deploy_erc3009_counterfactual_if_needed", lambda *a, **k: None + ) + + +class TestDepositPendingSettlementStoreCacheMiss: + def test_broadcast_success_leaves_store_empty(self, monkeypatch): + payload = _deposit_payload() + signer = _Signer() + store = InMemoryPendingSettlementStore() + monkeypatch.setattr(deposit_mod, "verify_deposit", lambda *a, **k: _valid_verify_response()) + monkeypatch.setattr( + deposit_mod, + "_resolve_deposit_execution", + lambda *a, **k: deposit_mod._DepositExecution( + kind="direct", + collector="0x9999999999999999999999999999999999999999", + collector_data=b"", + ), + ) + monkeypatch.setattr(deposit_mod, "read_channel_state", lambda *a, **k: _channel_state()) + + out = settle_deposit( + signer, _dummy_payment(), payload, _requirements(), pending_store=store + ) + + assert out.success is True + assert out.transaction == TX_HASH + assert store.get(_deposit_settlement_cache_key(payload, _requirements())) is None + + def test_receipt_wait_failure_populates_store_with_broadcast_hash(self, monkeypatch): + payload = _deposit_payload() + signer = _Signer(receipt_error=TimeoutError("rpc: timeout waiting for receipt")) + store = InMemoryPendingSettlementStore() + monkeypatch.setattr(deposit_mod, "verify_deposit", lambda *a, **k: _valid_verify_response()) + monkeypatch.setattr( + deposit_mod, + "_resolve_deposit_execution", + lambda *a, **k: deposit_mod._DepositExecution( + kind="direct", + collector="0x9999999999999999999999999999999999999999", + collector_data=b"", + ), + ) + + out = settle_deposit( + signer, _dummy_payment(), payload, _requirements(), pending_store=store + ) + + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert out.transaction == TX_HASH + assert store.get(_deposit_settlement_cache_key(payload, _requirements())) == TX_HASH + assert signer.write_calls == 1 + + +class TestDepositPendingSettlementStoreCacheHit: + def test_skips_verify_and_broadcast_then_reconciles_success(self, monkeypatch): + payload = _deposit_payload() + store = InMemoryPendingSettlementStore() + cache_key = _deposit_settlement_cache_key(payload, _requirements()) + store.set(cache_key, TX_HASH) + signer = _Signer() + monkeypatch.setattr( + deposit_mod, + "verify_deposit", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("verify_deposit must be skipped on a pending-store hit") + ), + ) + monkeypatch.setattr( + deposit_mod, + "_resolve_deposit_execution", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("_resolve_deposit_execution must be skipped on a pending-store hit") + ), + ) + monkeypatch.setattr(deposit_mod, "read_channel_state", lambda *a, **k: _channel_state()) + + out = settle_deposit( + signer, _dummy_payment(), payload, _requirements(), pending_store=store + ) + + assert out.success is True + assert out.transaction == TX_HASH + assert signer.write_calls == 0 # no second broadcast + assert store.get(cache_key) is None # cleared once confirmed + assert out.extra is not None + assert out.extra["channelState"]["balance"] == "500" + + def test_still_unconfirmed_returns_settlement_pending_again(self, monkeypatch): + payload = _deposit_payload() + store = InMemoryPendingSettlementStore() + cache_key = _deposit_settlement_cache_key(payload, _requirements()) + store.set(cache_key, TX_HASH) + signer = _Signer(receipt_error=TimeoutError("rpc: timeout waiting for receipt")) + monkeypatch.setattr( + deposit_mod, + "verify_deposit", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("verify_deposit must be skipped on a pending-store hit") + ), + ) + + out = settle_deposit( + signer, _dummy_payment(), payload, _requirements(), pending_store=store + ) + + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert out.transaction == TX_HASH + assert store.get(cache_key) == TX_HASH # re-marked pending, not lost + assert signer.write_calls == 0 # never re-broadcast + + +class TestDepositPendingSettlementStoreTerminalVerifyFailure: + def test_verify_failure_is_terminal_and_never_touches_store(self, monkeypatch): + payload = _deposit_payload() + signer = _Signer() + store = InMemoryPendingSettlementStore() + monkeypatch.setattr( + deposit_mod, + "verify_deposit", + lambda *a, **k: VerifyResponse( + is_valid=False, invalid_reason="insufficient_balance", payer=PAYER + ), + ) + + out = settle_deposit( + signer, _dummy_payment(), payload, _requirements(), pending_store=store + ) + + assert out.success is False + assert out.error_reason == "insufficient_balance" + assert store.get(_deposit_settlement_cache_key(payload, _requirements())) is None + assert signer.write_calls == 0 + + +class TestDepositSettlementCacheKey: + def test_uses_erc3009_signature_when_present(self): + payload = _deposit_payload(signature="0x" + "77" * 65) + assert _deposit_settlement_cache_key(payload, _requirements()) == "0x" + "77" * 65 + + def test_empty_when_no_authorization_signature_available(self): + payload = _deposit_payload() + payload.deposit.authorization.erc3009_authorization = None + assert _deposit_settlement_cache_key(payload, _requirements()) == "" + + def test_uses_permit2_signature_when_transfer_method_resolves_to_permit2(self, monkeypatch): + """The cache key must key on the authorization for the transfer method + `requirements` actually resolves to, not just whichever authorization field + happens to be populated — a payload carrying both shapes (or the wrong one for + the resolved method) must not accidentally key on the unused erc3009 signature. + Mirrors Go/TS. + """ + # This test needs the real resolver, not the file's autouse eip3009-only stub. + monkeypatch.undo() + + from x402.mechanisms.evm.batch_settlement.types import ( + Permit2Authorization, + Permit2DepositWitness, + Permit2TokenPermissions, + ) + + payload = _deposit_payload(signature="0x" + "77" * 65) + payload.deposit.authorization.permit2_authorization = Permit2Authorization( + from_address=PAYER, + permitted=Permit2TokenPermissions(token=TOKEN, amount="500"), + spender="0x000000000022D473030F116dDEE9F6B43aC78BA3", + nonce="1", + deadline="99999999999", + witness=Permit2DepositWitness(channel_id=CHANNEL_ID), + signature="0x" + "88" * 65, + ) + requirements = PaymentRequirements( + scheme="batch-settlement", + network=NETWORK, + asset=TOKEN, + amount="500", + pay_to="0x3333333333333333333333333333333333333333", + max_timeout_seconds=60, + extra={"assetTransferMethod": "permit2"}, + ) + + assert _deposit_settlement_cache_key(payload, requirements) == "0x" + "88" * 65 diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_scheme.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_scheme.py index a4a8614b7b..ac4b167d4b 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_scheme.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/facilitator/test_scheme.py @@ -97,6 +97,21 @@ def test_get_signers_returns_addresses(self): ) assert fac.get_signers(NETWORK) == ["0xabc"] + def test_uses_provided_pending_store_instead_of_a_fresh_default(self): + """A caller-supplied PendingSettlementStore must be the instance actually used, + not merely accepted and ignored in favor of the default. This is what lets a + multi-instance facilitator inject a shared, network-backed store.""" + from x402.pending_settlement_store import InMemoryPendingSettlementStore + + custom_store = InMemoryPendingSettlementStore() + + fac = BatchSettlementEvmFacilitator( + _FakeFacilitatorSigner(), # type: ignore[arg-type] + pending_store=custom_store, + ) + + assert fac._pending_store is custom_store + class TestVerifyDispatchErrors: def _fac(self): diff --git a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py index aa71a43302..ed97a5914a 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from unittest.mock import patch import pytest @@ -280,6 +281,13 @@ def get_code(self, address: str) -> bytes: return self.code +class _ReceiptTimeoutSigner(MockFacilitatorSigner): + """Signer whose broadcast never confirms in time (settlement_pending).""" + + def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: + raise TimeoutError("rpc: timeout waiting for receipt") + + class TestExactEvmSchemeConstructor: def test_creates_instance_with_config(self): signer = MockFacilitatorSigner() @@ -296,6 +304,19 @@ def test_creates_instance_with_config(self): ] assert facilitator._config.simulate_in_settle is True + def test_uses_provided_pending_store_instead_of_a_fresh_default(self): + """A caller-supplied PendingSettlementStore must be the instance actually used, + not merely accepted and ignored in favor of the default. This is what lets a + multi-instance facilitator inject a shared, network-backed store.""" + from x402.pending_settlement_store import InMemoryPendingSettlementStore + + signer = MockFacilitatorSigner() + custom_store = InMemoryPendingSettlementStore() + + facilitator = ExactEvmFacilitatorScheme(signer, pending_store=custom_store) + + assert facilitator._pending_store is custom_store + class TestVerify: def test_rejects_wrong_scheme(self): @@ -603,10 +624,6 @@ def test_can_rerun_simulation_during_settle(self): assert signer.write_calls == 1 def test_receipt_wait_failure_returns_settlement_pending(self): - class _ReceiptTimeoutSigner(MockFacilitatorSigner): - def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: - raise TimeoutError("rpc: timeout waiting for receipt") - # Payer has code so verify_typed_data_strict takes the EIP-1271 path, which # honours typed_data_valid=True via the isValidSignature mock. signer = _ReceiptTimeoutSigner(code_by_address={PAYER.lower(): b"\x01"}) @@ -647,6 +664,84 @@ def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: assert result.transaction == "0x" + "34" * 32 +class TestEip3009PendingSettlementStore: + """Pending-settlement store integration for the EIP-3009 settle path.""" + + def test_cache_miss_broadcast_success_leaves_store_empty(self): + signer = MockFacilitatorSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + result = facilitator.settle(payload, make_requirements()) + + assert result.success is True + assert facilitator._pending_store.entries == {} + + def test_cache_miss_wait_failure_populates_store_with_broadcast_hash(self): + signer = _ReceiptTimeoutSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + result = facilitator.settle(payload, make_requirements()) + + assert result.success is False + assert result.error_reason == ERR_SETTLEMENT_PENDING + signature = payload.payload["signature"] + assert facilitator._pending_store.get(signature) == result.transaction + + def test_cache_hit_skips_verify_and_broadcast_then_reconciles_success(self): + signer = _ReceiptTimeoutSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + first = facilitator.settle(payload, make_requirements()) + assert first.success is False + write_calls_after_first = signer.write_calls + + # The transaction actually confirms now. + def _confirmed_receipt(tx_hash: str) -> TransactionReceipt: + return TransactionReceipt(status=1, block_number=1, tx_hash=tx_hash) + + signer.wait_for_transaction_receipt = _confirmed_receipt + + with patch.object( + facilitator, "_verify", side_effect=AssertionError("verify must be skipped") + ): + second = facilitator.settle(payload, make_requirements()) + + assert second.success is True + assert second.transaction == first.transaction + assert signer.write_calls == write_calls_after_first # no second broadcast + assert facilitator._pending_store.entries == {} + + def test_cache_hit_still_unconfirmed_returns_settlement_pending_again(self): + signer = _ReceiptTimeoutSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + first = facilitator.settle(payload, make_requirements()) + second = facilitator.settle(payload, make_requirements()) + + assert second.success is False + assert second.error_reason == ERR_SETTLEMENT_PENDING + assert second.transaction == first.transaction + assert signer.write_calls == 1 # never re-broadcast + + def test_verify_only_failure_is_terminal_and_never_touches_store(self): + signer = MockFacilitatorSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + + result = facilitator.settle( + make_payment_payload(amount="50000"), + make_requirements(amount="100000"), + ) + + assert result.success is False + assert result.error_reason == ERR_AUTHORIZATION_VALUE_MISMATCH + assert facilitator._pending_store.entries == {} + assert signer.write_calls == 0 + + class TestSettleFactoryAllowlist: """ERC-6492 factory allowlist enforcement during settle.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_permit2.py b/python/x402/tests/unit/mechanisms/evm/test_permit2.py index b778d91ca2..daef342580 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_permit2.py +++ b/python/x402/tests/unit/mechanisms/evm/test_permit2.py @@ -187,6 +187,13 @@ def get_code(self, address: str) -> bytes: return b"" +class _ReceiptTimeoutSigner(MockFacilitatorSigner): + """Signer whose broadcast never confirms in time (settlement_pending).""" + + def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: + raise TimeoutError("rpc: timeout waiting for receipt") + + # ============================================================================ # Type detection tests # ============================================================================ @@ -461,10 +468,6 @@ def test_settle_fails_on_transaction_failure(self): assert result.success is False def test_settle_receipt_wait_failure_returns_settlement_pending(self): - class _ReceiptTimeoutSigner(MockFacilitatorSigner): - def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: - raise TimeoutError("rpc: timeout waiting for receipt") - signer = _ReceiptTimeoutSigner() facilitator = ExactEvmFacilitatorScheme(signer) payload = make_payment_payload() @@ -498,6 +501,88 @@ def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: assert result.error_reason == ERR_SETTLEMENT_PENDING assert result.transaction == "0x" + "ab" * 32 + def test_cache_miss_broadcast_success_leaves_store_empty(self): + signer = MockFacilitatorSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + + with patch( + "x402.mechanisms.evm.exact.permit2_utils._verify_permit2_signature", + return_value=True, + ): + result = facilitator.settle(make_payment_payload(), make_requirements()) + + assert result.success is True + assert facilitator._pending_store.entries == {} + + def test_cache_miss_wait_failure_populates_store_with_broadcast_hash(self): + signer = _ReceiptTimeoutSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.exact.permit2_utils._verify_permit2_signature", + return_value=True, + ): + result = facilitator.settle(payload, make_requirements()) + + assert result.success is False + assert result.error_reason == ERR_SETTLEMENT_PENDING + signature = payload.payload["signature"] + assert facilitator._pending_store.get(signature) == result.transaction + + def test_cache_hit_skips_verify_and_broadcast_then_reconciles_success(self): + signer = _ReceiptTimeoutSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.exact.permit2_utils._verify_permit2_signature", + return_value=True, + ): + first = facilitator.settle(payload, make_requirements()) + assert first.success is False + write_calls_after_first = len(signer.write_calls) + + signer.wait_for_transaction_receipt = lambda tx_hash: TransactionReceipt( + status=1, block_number=1, tx_hash=tx_hash + ) + + with patch( + "x402.mechanisms.evm.exact.permit2_utils.verify_permit2", + side_effect=AssertionError("verify must be skipped on a pending-store hit"), + ): + second = facilitator.settle(payload, make_requirements()) + + assert second.success is True + assert second.transaction == first.transaction + assert len(signer.write_calls) == write_calls_after_first # no second broadcast + assert facilitator._pending_store.entries == {} + + def test_cache_hit_still_unconfirmed_returns_settlement_pending_again(self): + signer = _ReceiptTimeoutSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.exact.permit2_utils._verify_permit2_signature", + return_value=True, + ): + first = facilitator.settle(payload, make_requirements()) + second = facilitator.settle(payload, make_requirements()) + + assert second.success is False + assert second.error_reason == ERR_SETTLEMENT_PENDING + assert second.transaction == first.transaction + assert len(signer.write_calls) == 1 # never re-broadcast + + def test_verify_only_failure_is_terminal_and_never_touches_store(self): + facilitator = self._make_facilitator(allowance=0) + + result = facilitator.settle(make_payment_payload(), make_requirements()) + + assert result.success is False + assert facilitator._pending_store.entries == {} + def test_settle_invalid_broadcast_hash_is_terminal(self): # settlement_pending needs the broadcast hash to be actionable, so a signer that # reports success without a usable hash must fail terminally. diff --git a/python/x402/tests/unit/mechanisms/evm/test_server.py b/python/x402/tests/unit/mechanisms/evm/test_server.py index 0380a46c6e..9c14375fc7 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_server.py +++ b/python/x402/tests/unit/mechanisms/evm/test_server.py @@ -12,6 +12,19 @@ from x402.schemas.helpers import convert_to_token_amount +class TestPaymentFlows: + """Test payment_flows configuration.""" + + def test_declares_authorization_and_upfront_with_authorization_as_default(self): + server = ExactEvmServerScheme() + + assert server.default_asset_transfer_method == "eip3009" + assert server.payment_flows == { + "eip3009": {"supported": ("authorization", "upfront"), "default": "authorization"}, + "permit2": {"supported": ("authorization", "upfront"), "default": "authorization"}, + } + + class TestParsePrice: """Test parsePrice method.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_settle_receipt.py b/python/x402/tests/unit/mechanisms/evm/test_settle_receipt.py index 7bb5709255..dbf2ce325c 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_settle_receipt.py +++ b/python/x402/tests/unit/mechanisms/evm/test_settle_receipt.py @@ -20,6 +20,7 @@ from x402.mechanisms.evm.constants import ERR_SETTLEMENT_PENDING, TX_STATUS_SUCCESS from x402.mechanisms.evm.settle_receipt import wait_for_receipt_and_build_response +from x402.pending_settlement_store import InMemoryPendingSettlementStore _TX = "0x" + "ab" * 32 _FAILED = "invalid_exact_evm_transaction_failed" @@ -139,3 +140,194 @@ def test_success_returns_hash_and_amount(): assert out.transaction == _TX assert out.amount == "100" assert out.payer == "0xpayer" + + +# ============================================================================ +# PendingSettlementStore mark/clear contract +# +# When pending_store and pending_key are both supplied, every outcome that leaves the +# broadcast's effect unresolved must record tx_hash (even a terminal reverted-receipt +# outcome — see the function's own docstring), and every outcome that resolves it +# (success, an invalid hash with nothing to reconcile against, or an explicit +# validate_receipt rejection of a confirmed receipt) must clear it. This is the single +# place all EVM schemes (exact, upto, batch-settlement) get this behavior from, so its +# store contract is pinned here directly rather than only indirectly via each scheme's +# own tests. +# ============================================================================ + +_KEY = "0xsomekey" + + +def test_invalid_broadcast_hash_clears_pending_when_store_provided(): + """An invalid hash means nothing usable was ever broadcast, so there is nothing to + reconcile against later — unlike a reverted receipt (a real, replayable on-chain + outcome), storing it would only make a retry re-observe the same invalid hash.""" + store = InMemoryPendingSettlementStore() + store.set(_KEY, "0xstaleprior") + + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt()), + "not-a-hash", + _NETWORK, + None, + failed_reason=_FAILED, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == _FAILED + assert store.get(_KEY) is None + + +def test_receipt_wait_failure_marks_pending_when_store_provided(): + store = InMemoryPendingSettlementStore() + out = wait_for_receipt_and_build_response( + _Signer(error=RuntimeError("rpc timeout")), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert store.get(_KEY) == _TX + + +def test_reverted_receipt_clears_pending_when_store_provided(): + """A reverted receipt is terminal (failed_reason, not settlement_pending). It must not + be cached — only settlement_pending is safe to reconcile against — or it would linger + as a false "pending" entry until TTL expiry.""" + store = InMemoryPendingSettlementStore() + store.set(_KEY, _TX) + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt(status=0)), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == _FAILED + assert store.get(_KEY) is None + + +def test_clean_validation_failure_clears_pending_when_store_provided(): + from x402.schemas import SettleResponse + + def _validate(_receipt): + return SettleResponse( + success=False, error_reason=_FAILED, transaction=_TX, network=_NETWORK + ) + + store = InMemoryPendingSettlementStore() + store.set(_KEY, _TX) + + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt()), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + validate_receipt=_validate, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == _FAILED + assert store.get(_KEY) is None + + +def test_validate_receipt_raising_marks_pending_when_store_provided(): + def _validate(_receipt): + raise ValueError("log decode failed") + + store = InMemoryPendingSettlementStore() + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt()), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + validate_receipt=_validate, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert store.get(_KEY) == _TX + + +def test_on_success_raising_marks_pending_when_store_provided(): + def _on_success(_receipt): + raise ValueError("amount parse failed") + + store = InMemoryPendingSettlementStore() + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt()), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + on_success=_on_success, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert store.get(_KEY) == _TX + + +def test_success_clears_pending_when_store_provided(): + store = InMemoryPendingSettlementStore() + store.set(_KEY, _TX) + + out = wait_for_receipt_and_build_response( + _Signer(receipt=_receipt()), + _TX, + _NETWORK, + "0xpayer", + failed_reason=_FAILED, + pending_store=store, + pending_key=_KEY, + ) + assert out.success is True + assert store.get(_KEY) is None + + +def test_no_pending_key_is_a_noop_even_with_store_provided(): + """An empty/falsy pending_key (e.g. a malformed payload with no signature to key on) + must disable the fast path store interaction entirely, not raise or store under a + falsy key.""" + store = InMemoryPendingSettlementStore() + + out = wait_for_receipt_and_build_response( + _Signer(error=RuntimeError("rpc timeout")), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + pending_store=store, + pending_key="", + ) + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING + assert store.entries == {} + + +def test_no_store_provided_is_a_noop_even_with_pending_key(): + """No pending_store at all (the default for callers that don't opt in) must not raise, + regardless of outcome.""" + out = wait_for_receipt_and_build_response( + _Signer(error=RuntimeError("rpc timeout")), + _TX, + _NETWORK, + None, + failed_reason=_FAILED, + pending_key=_KEY, + ) + assert out.success is False + assert out.error_reason == ERR_SETTLEMENT_PENDING diff --git a/python/x402/tests/unit/mechanisms/evm/test_signer.py b/python/x402/tests/unit/mechanisms/evm/test_signer.py index 1a46aca2dc..74af5220a9 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_signer.py +++ b/python/x402/tests/unit/mechanisms/evm/test_signer.py @@ -1,6 +1,7 @@ """Tests for EVM signer implementations.""" from types import SimpleNamespace +from unittest.mock import Mock import pytest @@ -136,6 +137,42 @@ def test_address_property_should_return_checksummed_address(self): assert signer.address == account.address + @pytest.mark.parametrize( + ("signer_kwargs", "expected_gas_limit"), + [ + pytest.param({}, 500_000, id="default"), + pytest.param({"gas_limit": 750_000}, 750_000, id="configured"), + ], + ) + def test_transactions_use_gas_limit(self, signer_kwargs, expected_gas_limit): + """Transactions should use the default or configured gas limit.""" + account = Account.create() + signer = FacilitatorWeb3Signer( + private_key=account.key.hex(), + rpc_url="https://sepolia.base.org", + **signer_kwargs, + ) + builder = Mock() + builder.build_transaction.side_effect = lambda tx: tx + signer._w3 = Mock() + signer._w3.eth.contract.return_value.functions.transfer = Mock(return_value=builder) + signer._w3.eth.get_transaction_count.return_value = 0 + signer._w3.eth.gas_price = 1 + signer._w3.eth.send_raw_transaction.return_value = bytes.fromhex("12" * 32) + signer._account.sign_transaction = Mock( + return_value=SimpleNamespace(raw_transaction=b"signed") + ) + + signer.write_contract("0x1111111111111111111111111111111111111111", [], "transfer") + signer.send_transaction("0x2222222222222222222222222222222222222222", b"") + + assert [ + call.args[0]["gas"] for call in signer._account.sign_transaction.call_args_list + ] == [ + expected_gas_limit, + expected_gas_limit, + ] + def test_should_have_required_methods(self): """Should have all required facilitator signer methods.""" account = Account.create() diff --git a/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py index d97866ff92..713173070a 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py @@ -201,6 +201,13 @@ def get_code(self, address: str) -> bytes: return self._code_by_address.get(address.lower(), self._code) +class _ReceiptTimeoutSigner(MockFacilitatorSigner): + """Signer whose broadcast never confirms in time (settlement_pending).""" + + def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: + raise TimeoutError("rpc: timeout waiting for receipt") + + class TestUptoEvmSchemeConstructor: def test_creates_instance_with_correct_scheme(self): signer = MockFacilitatorSigner() @@ -208,6 +215,19 @@ def test_creates_instance_with_correct_scheme(self): assert facilitator.scheme == "upto" assert facilitator.caip_family == "eip155:*" + def test_uses_provided_pending_store_instead_of_a_fresh_default(self): + """A caller-supplied PendingSettlementStore must be the instance actually used, + not merely accepted and ignored in favor of the default. This is what lets a + multi-instance facilitator inject a shared, network-backed store.""" + from x402.pending_settlement_store import InMemoryPendingSettlementStore + + signer = MockFacilitatorSigner() + custom_store = InMemoryPendingSettlementStore() + + facilitator = UptoEvmFacilitatorScheme(signer, pending_store=custom_store) + + assert facilitator._pending_store is custom_store + class TestGetExtra: def test_returns_facilitator_address(self): @@ -496,10 +516,6 @@ def test_settle_fails_on_transaction_failure(self): def test_receipt_wait_failure_returns_settlement_pending(self): from unittest.mock import patch - class _ReceiptTimeoutSigner(MockFacilitatorSigner): - def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: - raise TimeoutError("rpc: timeout waiting for receipt") - signer = _ReceiptTimeoutSigner() facilitator = UptoEvmFacilitatorScheme(signer) @@ -869,3 +885,98 @@ def test_erc20_approval_broadcast_failed(self): def test_default_maps_to_upto_transaction_failed(self): assert self._call("some unknown revert") == "invalid_upto_evm_transaction_failed" + + +class TestUptoPermit2PendingSettlementStore: + """Pending-settlement store integration for the upto Permit2 settle path.""" + + def test_cache_miss_broadcast_success_leaves_store_empty(self): + from unittest.mock import patch + + signer = MockFacilitatorSigner(sig_valid=True) + facilitator = UptoEvmFacilitatorScheme(signer) + + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + result = facilitator.settle(make_payment_payload(), make_requirements()) + + assert result.success is True + assert facilitator._pending_store.entries == {} + + def test_cache_miss_wait_failure_populates_store_with_broadcast_hash(self): + from unittest.mock import patch + + signer = _ReceiptTimeoutSigner() + facilitator = UptoEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + result = facilitator.settle(payload, make_requirements()) + + assert result.success is False + assert result.error_reason == ERR_SETTLEMENT_PENDING + signature = payload.payload["signature"] + assert facilitator._pending_store.get(signature) == result.transaction + + def test_cache_hit_skips_verify_and_broadcast_then_reconciles_success(self): + from unittest.mock import patch + + signer = _ReceiptTimeoutSigner() + facilitator = UptoEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + first = facilitator.settle(payload, make_requirements()) + assert first.success is False + write_calls_after_first = len(signer.write_calls) + + signer.wait_for_transaction_receipt = lambda tx_hash: TransactionReceipt( + status=1, block_number=1, tx_hash=tx_hash + ) + + with patch( + "x402.mechanisms.evm.upto.permit2_utils.verify_upto_permit2", + side_effect=AssertionError("verify must be skipped on a pending-store hit"), + ): + second = facilitator.settle(payload, make_requirements()) + + assert second.success is True + assert second.transaction == first.transaction + assert second.amount == AMOUNT + assert len(signer.write_calls) == write_calls_after_first # no second broadcast + assert facilitator._pending_store.entries == {} + + def test_cache_hit_still_unconfirmed_returns_settlement_pending_again(self): + from unittest.mock import patch + + signer = _ReceiptTimeoutSigner() + facilitator = UptoEvmFacilitatorScheme(signer) + payload = make_payment_payload() + + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + first = facilitator.settle(payload, make_requirements()) + second = facilitator.settle(payload, make_requirements()) + + assert second.success is False + assert second.error_reason == ERR_SETTLEMENT_PENDING + assert second.transaction == first.transaction + assert len(signer.write_calls) == 1 # never re-broadcast + + def test_verify_only_failure_is_terminal_and_never_touches_store(self): + facilitator = UptoEvmFacilitatorScheme(MockFacilitatorSigner(sig_valid=False)) + + result = facilitator.settle(make_payment_payload(), make_requirements()) + + assert result.success is False + assert facilitator._pending_store.entries == {} diff --git a/python/x402/tests/unit/mechanisms/svm/test_facilitator.py b/python/x402/tests/unit/mechanisms/svm/test_facilitator.py index 0ff7a3fd31..75d876fe6e 100644 --- a/python/x402/tests/unit/mechanisms/svm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/svm/test_facilitator.py @@ -10,6 +10,7 @@ USDC_DEVNET_ADDRESS, ) from x402.mechanisms.svm.exact import ExactSvmFacilitatorScheme +from x402.mechanisms.svm.utils import transaction_message_hash from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo, VerifyResponse @@ -37,6 +38,13 @@ def confirm_transaction(self, signature: str, network: str) -> None: pass +class _ConfirmTimeoutSigner(MockFacilitatorSigner): + """Signer whose broadcast never confirms in time (settlement_pending).""" + + def confirm_transaction(self, signature: str, network: str) -> None: + raise TimeoutError("rpc: timeout waiting for confirmation") + + class TestExactSvmSchemeConstructor: """Test ExactSvmScheme facilitator constructor.""" @@ -47,6 +55,18 @@ def test_should_create_instance_with_correct_scheme(self): assert facilitator.scheme == "exact" + def test_uses_provided_pending_store_instead_of_a_fresh_default(self): + """A caller-supplied PendingSettlementStore must be the instance actually used, + not merely accepted and ignored in favor of the default. This is what lets a + multi-instance facilitator inject a shared, network-backed store.""" + from x402.pending_settlement_store import InMemoryPendingSettlementStore + + signer = MockFacilitatorSigner() + custom_store = InMemoryPendingSettlementStore() + facilitator = ExactSvmFacilitatorScheme(signer, pending_store=custom_store) + + assert facilitator._pending_store is custom_store + class TestVerify: """Test verify method.""" @@ -247,12 +267,238 @@ def test_should_fail_settlement_if_verification_fails(self): extra={"feePayer": "FeePayer1111111111111111111111111111"}, ) - result = facilitator.settle(payload, requirements) + # settle() now decodes the transaction up front (to key the pending-settlement + # store) before verify() runs, so an arbitrary test string needs a fake decode to + # reach the scheme-mismatch check below. + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda payload: _FakeTx(payload.transaction), + ): + result = facilitator.settle(payload, requirements) assert result.success is False assert result.error_reason == "unsupported_scheme" assert result.network == SOLANA_DEVNET_CAIP2 + def test_should_fail_settlement_with_settlement_pending_on_confirm_timeout(self): + """Confirm-timeout must report settlement_pending (not transaction_failed) and + populate the pending-settlement store with the broadcast signature.""" + + signer = _ConfirmTimeoutSigner() + facilitator = ExactSvmFacilitatorScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network=SOLANA_DEVNET_CAIP2, + asset=USDC_DEVNET_ADDRESS, + amount="100000", + pay_to="PayToAddress11111111111111111111111111", + max_timeout_seconds=3600, + extra={"feePayer": "FeePayer1111111111111111111111111111"}, + ) + payload = PaymentPayload( + x402_version=2, + resource=ResourceInfo( + url="http://example.com/protected", + description="Test resource", + mime_type="application/json", + ), + accepted=requirements, + payload={"transaction": "pendingTransaction=="}, + ) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda payload: _FakeTx(payload.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + result = facilitator.settle(payload, requirements) + + assert result.success is False + assert result.error_reason == "settlement_pending" + assert result.transaction == "mockSignature123" + assert ( + facilitator._pending_store.get( + transaction_message_hash(_FakeTx("pendingTransaction==")) + ) + == "mockSignature123" + ) + + +def _make_settle_fixtures(signer, transaction: str = "cachedTransaction=="): + facilitator = ExactSvmFacilitatorScheme(signer) + requirements = PaymentRequirements( + scheme="exact", + network=SOLANA_DEVNET_CAIP2, + asset=USDC_DEVNET_ADDRESS, + amount="100000", + pay_to="PayToAddress11111111111111111111111111", + max_timeout_seconds=3600, + extra={"feePayer": "FeePayer1111111111111111111111111111"}, + ) + payload = PaymentPayload( + x402_version=2, + resource=ResourceInfo( + url="http://example.com/protected", + description="Test resource", + mime_type="application/json", + ), + accepted=requirements, + payload={"transaction": transaction}, + ) + return facilitator, payload, requirements + + +class TestSettlePendingSettlementStoreReconciliation: + """Cache-hit reconciliation and store-lifecycle tests for ExactSvmScheme.settle().""" + + def test_cache_miss_broadcast_success_leaves_store_empty(self): + signer = MockFacilitatorSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + result = facilitator.settle(payload, requirements) + + assert result.success is True + assert ( + facilitator._pending_store.get( + transaction_message_hash(_FakeTx(payload.payload["transaction"])) + ) + is None + ) + + def test_cache_hit_skips_verify_and_send_then_reconciles_success(self): + signer = _ConfirmTimeoutSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + first = facilitator.settle(payload, requirements) + assert first.success is False + + # The signature confirms on the next attempt. + signer.confirm_transaction = lambda signature, network: None + with patch.object( + facilitator, + "verify", + side_effect=AssertionError("verify must be skipped on a pending-store hit"), + ): + second = facilitator.settle(payload, requirements) + + assert second.success is True + assert second.transaction == first.transaction + tx_key = transaction_message_hash(_FakeTx(payload.payload["transaction"])) + assert facilitator._pending_store.get(tx_key) is None + + def test_cache_hit_still_unconfirmed_returns_settlement_pending_again(self): + signer = _ConfirmTimeoutSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + first = facilitator.settle(payload, requirements) + second = facilitator.settle(payload, requirements) + + assert first.success is False + assert second.success is False + assert second.error_reason == "settlement_pending" + assert second.transaction == first.transaction + + def test_verify_only_failure_is_terminal_and_never_touches_store(self): + signer = MockFacilitatorSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=False, invalid_reason="invalid_signature"), + ): + result = facilitator.settle(payload, requirements) + + assert result.success is False + assert result.error_reason == "invalid_signature" + tx_key = transaction_message_hash(_FakeTx(payload.payload["transaction"])) + assert facilitator._pending_store.get(tx_key) is None + + def test_confirm_timeout_preserves_duplicate_cache_entry(self): + """A confirm-wait timeout is non-terminal (the transaction really was broadcast), so + the dedup lock must stay held — otherwise a fresh settle for the identical payload + (e.g. from a caller without a shared PendingSettlementStore) could re-verify and + re-send a transaction that's already in flight. Mirrors the Go/TS SVM exact tests. + """ + + signer = _ConfirmTimeoutSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + result = facilitator.settle(payload, requirements) + + assert result.success is False + tx_key = transaction_message_hash(_FakeTx(payload.payload["transaction"])) + assert facilitator._settlement_cache.is_duplicate(tx_key) is True + + def test_send_failure_is_terminal_and_deletes_duplicate_cache_entry(self): + class _SendFailsSigner(MockFacilitatorSigner): + def send_transaction(self, tx_base64: str, network: str) -> str: + raise RuntimeError("rpc: connection refused") + + signer = _SendFailsSigner() + facilitator, payload, requirements = _make_settle_fixtures(signer) + + with patch( + "x402.mechanisms.svm.exact.facilitator.decode_transaction_from_payload", + side_effect=lambda p: _FakeTx(p.transaction), + ): + with patch.object( + facilitator, + "verify", + return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), + ): + facilitator.settle(payload, requirements) + tx_key = transaction_message_hash(_FakeTx(payload.payload["transaction"])) + + # The failed send must not leave the transaction stuck in the duplicate + # cache — a retry with the identical payload should reach send again + # rather than being rejected as a duplicate. + assert facilitator._settlement_cache.is_duplicate(tx_key) is False + class TestFacilitatorSchemeAttributes: """Test facilitator scheme attributes.""" diff --git a/python/x402/tests/unit/mechanisms/svm/test_server.py b/python/x402/tests/unit/mechanisms/svm/test_server.py index e0ca0537b9..9765e7e0fe 100644 --- a/python/x402/tests/unit/mechanisms/svm/test_server.py +++ b/python/x402/tests/unit/mechanisms/svm/test_server.py @@ -18,6 +18,18 @@ from x402.schemas.helpers import convert_to_token_amount +class TestPaymentFlows: + """Test payment_flows configuration.""" + + def test_declares_authorization_and_upfront_with_authorization_as_default(self): + server = ExactSvmServerScheme() + + assert server.default_asset_transfer_method == "default" + assert server.payment_flows == { + "default": {"supported": ("authorization", "upfront"), "default": "authorization"}, + } + + class TestParsePrice: """Test parsePrice method.""" diff --git a/python/x402/tests/unit/mechanisms/tvm/test_server.py b/python/x402/tests/unit/mechanisms/tvm/test_server.py index 27ca7884db..bcc192bcfd 100644 --- a/python/x402/tests/unit/mechanisms/tvm/test_server.py +++ b/python/x402/tests/unit/mechanisms/tvm/test_server.py @@ -23,6 +23,18 @@ ZERO_BIT_PAYLOAD_B64 = EMPTY_FORWARD_PAYLOAD_B64 +class TestPaymentFlows: + """Test payment_flows configuration.""" + + def test_declares_authorization_and_upfront_with_authorization_as_default(self): + server = ExactTvmServerScheme() + + assert server.default_asset_transfer_method == "default" + assert server.payment_flows == { + "default": {"supported": ("authorization", "upfront"), "default": "authorization"}, + } + + def _make_requirements(**overrides): return make_tvm_requirements( asset=overrides.pop("asset", USDT_TESTNET_MINTER), diff --git a/python/x402/uv.lock b/python/x402/uv.lock index 161bc7cb30..56d660effa 100644 --- a/python/x402/uv.lock +++ b/python/x402/uv.lock @@ -3871,7 +3871,7 @@ wheels = [ [[package]] name = "x402" -version = "2.20.0" +version = "2.21.0" source = { editable = "." } dependencies = [ { name = "nest-asyncio" }, diff --git a/specs/extensions/bazaar.md b/specs/extensions/bazaar.md index df5724d940..442708e76d 100644 --- a/specs/extensions/bazaar.md +++ b/specs/extensions/bazaar.md @@ -470,13 +470,23 @@ Search responses may include: | `pagination.limit` | `number` | Yes (when `pagination` is an object) | Number of results in this page | | `pagination.cursor` | `string` or `null` | Yes (when `pagination` is an object) | Cursor for the next page, or `null` if unavailable | -### Verify and Settlement Response Header +### Verify and Settlement Response Sidechannel -After processing a `PaymentPayload`, a facilitator **MAY** append an `EXTENSION-RESPONSES` HTTP header to the verify or settlement response to communicate extension-specific outcomes to the client. +Extension data flows through three distinct channels: -**Header name:** `EXTENSION-RESPONSES` +| Channel | Wire | Audience | Purpose | +| ------- | ---- | -------- | ------- | +| `PaymentRequired.extensions` | 402 response body | Buyer and server | Extension declarations the buyer echoes | +| `SettleResponse.extensions` | `PAYMENT-RESPONSE` | Buyer and server | Settlement metadata both may consume; server enriches via hooks | +| `EXTENSION-RESPONSES` object | Facilitator transport sidechannel (HTTP header today) | Server internal only | Facilitator extension processing outcomes; **never forwarded to the buyer** | -**Header value:** A base64-encoded JSON object keyed by extension name. The `bazaar` key contains the bazaar extension's response: +On HTTP, facilitators communicate the sidechannel using the `EXTENSION-RESPONSES` header. The transport definition and general keyed-object format are specified in [x402-specification-v2 §7.2.1](../x402-specification-v2.md#721-extension-responses-sidechannel). This section defines the `bazaar` payload under the `bazaar` key. + +After processing a `PaymentPayload`, a facilitator **MAY** append extension outcomes to the sidechannel on verify or settlement responses. + +**HTTP header name:** `EXTENSION-RESPONSES` + +**HTTP header value:** A base64-encoded JSON object keyed by extension name. The `bazaar` key contains the bazaar extension's response: | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -505,7 +515,6 @@ EXTENSION-RESPONSES: eyJiYXphYXIiOnsic3RhdHVzIjoicmVqZWN0ZWQiLCJyZWplY3RlZFJlYXN ``` *(base64 of `{"bazaar":{"status":"rejected","rejectedReason":"info failed schema validation"}}`)* -Clients that understand the `bazaar` extension SHOULD read the `bazaar` key of this header to confirm cataloging succeeded and surface any rejection reason for debugging. --- diff --git a/specs/extensions/builder_code.md b/specs/extensions/builder_code.md index 3dbac60a4b..15a083662a 100644 --- a/specs/extensions/builder_code.md +++ b/specs/extensions/builder_code.md @@ -167,12 +167,11 @@ Declaring more than a party's own reservation at that layer MUST be rejected (se When a facilitator settles a payment containing the `builder-code` extension, it: -1. When the resource server declared `builder-code.info.a`, verifies that `PaymentPayload.extensions["builder-code"].a` matches `PaymentRequired.extensions["builder-code"].info.a` -2. Reads `a` (app code) and `s` (service codes) from the payment payload extensions -3. Adds its own builder code as the `w` (wallet) field -4. Optionally appends its own service code to `s` (deduped against the echoed entries), up to its `MAX_FACILITATOR_SERVICE_CODES` reservation -5. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix -6. Appends the suffix to the settlement transaction calldata +1. Reads `a` (app code) and `s` (service codes) from the payment payload extensions +2. Adds its own builder code as the `w` (wallet) field +3. Optionally appends its own service code to `s` (deduped against the echoed entries), up to its `MAX_FACILITATOR_SERVICE_CODES` reservation +4. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix +5. Appends the suffix to the settlement transaction calldata The facilitator's builder code and service code are configured at initialization and validated against the same `^[a-z0-9_]{1,32}$` pattern. @@ -289,7 +288,7 @@ The resource server MUST also reject the payment (`extension_echo_mismatch`) bef ### App Code Echo Validation -When the resource server declared `builder-code` in `PaymentRequired`, the facilitator MUST verify that the `a` field echoed by the client in `PaymentPayload.extensions["builder-code"]` exactly matches the `a` field declared by the application in `PaymentRequired.extensions["builder-code"].info`. A mismatch indicates the client tampered with the attribution and the payment MUST be rejected. +The resource server is the authority for `a`. Before forwarding a v2 payment to the facilitator, the resource server MUST reject the payment (`extension_echo_mismatch`) when `PaymentPayload.extensions["builder-code"].a` is present and does not exactly match `PaymentRequired.extensions["builder-code"].info.a` (including when the server did not declare `a`). ### Schema Validation diff --git a/specs/schemes/auth-capture/scheme_auth_capture.md b/specs/schemes/auth-capture/scheme_auth_capture.md index c17662c4d0..0bd9649218 100644 --- a/specs/schemes/auth-capture/scheme_auth_capture.md +++ b/specs/schemes/auth-capture/scheme_auth_capture.md @@ -2,84 +2,101 @@ ## Summary -`auth-capture` is a payment scheme where funds can be held and settled later. The client authorizes a maximum amount, and the facilitator submits it — either locking funds in escrow for later settlement (two-phase) or sending them directly to the receiver with refund capability (single-shot). +`auth-capture` is a payment scheme in which the client authorizes a maximum amount, and the payment then has a lifecycle: it can be held before it is finalized, finalized for less than the maximum, cancelled outright, or returned after the fact. Where `exact` moves a fixed amount once and offers no way to give it back, `auth-capture` is for payments whose final amount is not known when the client authorizes, or which may later need to be undone. -The **captureAuthorizer** is the entity authorized to authorize, capture, void, refund, or charge a payment. In a facilitator-submits flow, that's either the facilitator itself or any smart contract that ends up calling the underlying escrow. +## Example use cases -Unlike `exact`, which has no built-in mechanism for returning funds, `auth-capture` supports returning funds to the client through void, refund, and reclaim. +- Refundable payments with buyer protection. +- Delayed delivery, where the client needs recourse if the service turns out to be unsatisfactory. +- Metered work priced only once it completes: hold the ceiling, capture the actual cost. +- Subscription or session billing with periodic captures against one authorization. -## Example Use Cases +## Lifecycle operations -- Refundable payments with buyer protection -- Delayed delivery where the client needs recourse if the service is unsatisfactory -- Subscription or session billing with periodic captures against a single authorization -## Settlement Paths +| Operation | Effect | Repeatable | +| ----------- | --------------------------------------------------------------- | ----------------------------------------------------- | +| `authorize` | Reserves the client's funds, where they are held. | No — once per payment. | +| `charge` | Collects and distributes the funds in one step, with no hold. | No — once per payment. | +| `capture` | Pays held funds out to the receiver. | Yes — up to the held total. | +| `void` | Releases the remaining hold back to the client. | No — only while a hold remains. | +| `refund` | Returns captured funds to the client. | Yes — up to the amount captured and not yet refunded. | +| `reclaim` | Client recovers its own hold after the capture deadline passes. | No. | -The scheme supports two settlement paths, selected via `extra.autoCapture`: -| `autoCapture` | Behavior | -| :---------------- | :--------------------------------------------------------------------------------------------------------------------------- | -| `false` (default) | Two-phase. Funds held in escrow. CaptureAuthorizer can capture, void, refund. Client can reclaim if capture deadline passes. | -| `true` | Single-shot. Funds sent directly to receiver. CaptureAuthorizer can refund post-settlement. | +`reclaim` is the client's unilateral escape hatch and is never relayed through the facilitator. Every other operation is initiated by the resource server and relayed by the facilitator, except where a network binding runs later lifecycle operations out of band. -### Two-phase (`autoCapture: false`, default) +## Payment flows -``` -AUTHORIZE → RESOURCE DELIVERED → CAPTURE / VOID → (REFUND) -``` +Which operations a payment can undergo follows from its payment flow: -1. **Authorize**: Client authorization is submitted — funds locked in escrow. -2. **Resource delivered**: Server returns the resource (HTTP 200). -3. **Capture or void**: The captureAuthorizer can capture (finalize funds to the receiver) or void (release escrowed funds back to client). -4. **Reclaim**: If the capture deadline passes without action, the client can reclaim directly. -5. **Refund**: After capture, the captureAuthorizer can refund within the refund window. -### Single-shot (`autoCapture: true`) +| `extra.paymentFlow` | Ordering | Lifecycle | +| ------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `escrow` | settle → resource → settle | `authorize` places the hold before the resource runs, then `capture` or `void` afterwards, and `refund` later still. If the capture deadline passes with the hold untouched, the client can `reclaim`. | +| `authorization` | verify → resource → settle | `charge` after the resource runs, and `refund` later. No hold, therefore no `capture`, `void`, or `reclaim`. | -``` -CHARGE → RESOURCE DELIVERED → (REFUND) -``` -1. **Charge**: Client authorization is submitted — funds sent directly to receiver. -2. **Resource delivered**: Server returns the resource (HTTP 200). -3. **Refund**: The captureAuthorizer can refund within the refund window. +The scheme default is `escrow`. -No capture, void, or reclaim — funds are never held in escrow. +## Lifecycle operations are `/settle` calls -## Core Properties +Because a payment can settle more than once, the facilitator must be able to tell the settlements apart. The payment flow already fixes whether the client's authorization is settled as `authorize` or as `charge`, so the client names no operation. Later operations are named on the settle payload (for example `payload.type` of `"capture"`, `"void"`, or `"refund"`). No new facilitator endpoint is involved. -### Fund Safety +Those later lifecycle settles are outside the `escrow` ordering above. The flow's post-resource settle is the in-request finalize; further `capture`, `void`, and `refund` calls are scheme lifecycle, not a rewrite of that ordering. `refund` is always after the fact. -- Cannot overcharge — settlement amount is capped by the client-signed maximum. -- Two-phase path: client can reclaim escrowed funds after the capture deadline if no action is taken. -- Fee bounds are client-signed and enforced at settlement. +For `escrow`, that post-resource `capture` (or `void`) MAY complete synchronously during the request, or asynchronously after it returns. Asynchronous finalize requires the resource server to retain durable state for the payment. Sync versus async is a server choice; the wire format does not name a mode. -### Replay Prevention +A network binding MAY relay only the collect settle (`authorize` or `charge`) and leave later lifecycle operations out of band. -- Each payment has a unique nonce derived from the payment parameters and a fresh client-generated salt. -- Nonce is consumed on-chain at settlement, preventing double-spend. +## Core properties -### Expiry Enforcement +**Fund safety.** The amount settled is capped by the client-authorized maximum, and any fee is bounded by client-authorized limits. Held funds remain recoverable once the capture deadline passes, so a resource server that abandons a payment cannot strand it forever. -Two absolute-timestamp deadlines govern the payment lifecycle (network-specific implementations may add a derived pre-approval expiry from `maxTimeoutSeconds`): +**Payment identity.** Each payment has a unique identity. A collected authorization cannot be collected again. -- **Capture deadline** (`captureDeadline`): Last moment to capture escrowed funds (two-phase); after this, the client can reclaim. -- **Refund deadline** (`refundDeadline`): Last moment to issue a refund on captured or charged payments. +**Expiry enforcement.** Two absolute timestamps govern the lifecycle. The capture deadline (`extra.captureDeadline`) is the last moment held funds can be captured, and the moment `reclaim` becomes available. The refund deadline (`extra.refundDeadline`) is the last moment a refund can be issued. A network binding MAY derive further deadlines, such as a pre-approval expiry from `maxTimeoutSeconds`. + +**Server consent.** `charge` (the settled amount), `capture`, `void`, and `refund` are not client-authorized. A network binding MUST authenticate that facilitator-relayed operations of those kinds are consented to by the resource server (or run them out of band under the operator's own rules). `authorize` is client-authorized. + +**Replay protection.** `capture` and `refund` are repeatable up to remaining balances; each consent for them MUST be single-use. Who enforces that — facilitator or settlement layer — is binding-defined. ## Relationship to `exact` -| Aspect | `exact` | `auth-capture` | -| :--------- | :----------------- | :-------------------------------------------------------------------- | -| Settlement | Immediate transfer | Via escrow (two-phase) or direct with refund capability (single-shot) | -| Refundable | No | Yes (both paths) | -| Fee system | None | Configurable (min/max bounds, client-signed) | + +| Aspect | `exact` | `auth-capture` | +| ---------- | --------------------------------------- | -------------------------------------------------------------------- | +| Amount | Fixed, known when the client authorizes | Up to a client-authorized maximum, finalized later | +| Settlement | One transfer | Hold then capture (`escrow`), or direct (`authorization`) | +| Reversible | No | Yes — `void` before capture, `refund` after, `reclaim` by the client | +| Fees | None | Client-bounded, taken at capture or charge | + ## Appendix -Network-specific implementation details (contracts, signature formats, verification logic) are in per-network documents: `scheme_auth_capture_evm.md` (EVM). +### Network requirements + +Every `auth-capture` network binding MUST specify: + +1. **Hold / settlement mechanism** — how funds are reserved, captured, voided, and refunded, and how deadlines are enforced. +2. **Client authorization format** — the payload the client produces, and how the payment's identity derives from it. +3. **Operator model** — who may drive each lifecycle operation, and which operations the facilitator relays. +4. **Server consent** — how facilitator-relayed lifecycle operations are authenticated as originating from the resource server. +5. **Replay protection** — how repeatable operations (`capture`, `refund`) are made single-use per consent. +6. **Per-operation verification and settlement** — the checks a facilitator runs for each operation, and the call it makes. +7. **Refund funding** — who supplies refund liquidity, given that a facilitator must not be an unexpected source of value. +8. **Sync capture-and-void** — how a single `/settle` can finalize a partial capture and release any remainder when lifecycle is facilitator-relayed. + +### Network bindings + +- [`scheme_auth_capture_evm.md`](./scheme_auth_capture_evm.md) — EVM. + +## Version History + + +| Version | Date | Changes | Authors | +| ------- | ---------- | ------------------------------------------ | --------- | +| v1.1 | 2026-08-18 | Payment flow lifecycles and operator types | @phdargen | +| v1.0 | 2026-05-13 | Initial draft | @A1igator | -### References -- [Escrow Scheme Proposal — Agentokratia (Issue #834)](https://github.com/coinbase/x402/issues/834) -- [Escrow Scheme Proposal — x402r (Issue #1011)](https://github.com/coinbase/x402/issues/1011) diff --git a/specs/schemes/auth-capture/scheme_auth_capture_evm.md b/specs/schemes/auth-capture/scheme_auth_capture_evm.md index e92c036d50..d1e8fa4bec 100644 --- a/specs/schemes/auth-capture/scheme_auth_capture_evm.md +++ b/specs/schemes/auth-capture/scheme_auth_capture_evm.md @@ -2,19 +2,77 @@ ## Summary -The `auth-capture` scheme on EVM uses the [base/commerce-payments](https://github.com/base/commerce-payments) contract stack: +This is the EVM binding of [`auth-capture`](./scheme_auth_capture.md). It specifies the contracts, wire fields, signatures, and facilitator logic that realize the scheme on EVM chains. -- **AuthCaptureEscrow**: Singleton — locks funds, enforces expiries, distributes on capture/refund. Universal canonical address (same address on every supported chain). -- **Token Collectors**: Universal canonical addresses, one per `assetTransferMethod`: - - `EIP3009_TOKEN_COLLECTOR_ADDRESS` — collects funds via `receiveWithAuthorization` signatures (USDC, EURC, etc.) - - `PERMIT2_TOKEN_COLLECTOR_ADDRESS` — collects funds via Uniswap Permit2 `permitTransferFrom` (any ERC-20) -- **`captureAuthorizer`**: Address authorized to authorize, capture, void, refund, or charge a payment. The escrow contract gates those operations on `msg.sender` matching this address. In x402's facilitator-submits flow that means either **the facilitator's EOA**, or **any smart contract** that ends up calling the escrow (e.g., an arbiter contract with dispute logic, a multisig, etc.). +The binding builds on the [base/commerce-payments](https://github.com/base/commerce-payments) contract stack. Two CREATE2 deployments are specified, selected by OPTIONAL `extra.authCaptureEscrow` as [Commerce-payments deployments](#commerce-payments-deployments) sets out. The default is the v1.1 set. -The client signs a single signature (ERC-3009 or Permit2). The facilitator calls `AuthCaptureEscrow.authorize()` (two-phase) or `AuthCaptureEscrow.charge()` (single-shot via `autoCapture: true`), either directly or through a smart contract set as the captureAuthorizer. +- `AuthCaptureEscrow` — the escrow singleton. It holds funds, enforces the expiry ordering, moves value on every operation, and gates each of them on `msg.sender == paymentInfo.operator`. Its address is the same on every supported chain for a given deployment. +- **Token collectors** — one canonical contract per funding path in that deployment, each turning an authorization into a token pull: + - `EIP3009_TOKEN_COLLECTOR_ADDRESS` — collects from the payer via ERC-3009 `receiveWithAuthorization` (USDC, EURC, and other EIP-3009 tokens). + - `PERMIT2_TOKEN_COLLECTOR_ADDRESS` — collects from the payer via Uniswap Permit2 `permitTransferFrom` (any ERC-20). + - `OPERATOR_REFUND_COLLECTOR_ADDRESS` — collects refund liquidity from `paymentInfo.operator`. -## PaymentRequirements +The client produces exactly one signature: an ERC-3009 or Permit2 authorization naming a collector as the recipient. Which later operations the facilitator also relays depends on `extra.operatorType`. Facilitator-relayed `charge`, `capture`, `void`, and `refund` each require an EIP-712 signature from the receiver authorizer; `authorize` does not — the client's token authorization is sufficient. + +## Operator types + +`extra.operatorType` names the kind of `extra.captureAuthorizer`. That address is committed onchain as `PaymentInfo.operator`. + +Two kinds are specified: `"delegated"`, where the facilitator is the operator, and `"custom"`, where a custom smart contract is. A third value, `"policy"`, is RESERVED for the contract operators in [Future operator type: `policy`](#future-operator-type-policy); `extra.policy` is defined and bound into the payment's identity already, so that adding that type later changes no field and no derivation. + +The kinds are choices about who submits which calls. They are defined in terms of the escrow's own functions, referred to below as the **escrow ABI**. The **collect** operations are `authorize` and `charge` — whichever `extra.paymentFlow` selects for the client's payload. The **lifecycle** operations are `capture`, `void`, and `refund`. The signatures below are the v1.1 escrow. When `extra.authCaptureEscrow` is the v1.0 escrow, `charge` and `capture` take `uint16 feeBps` in place of `uint256 feeAmount`; `authorize`, `void`, and `refund` are identical across both. A `"custom"` operator MUST expose the collect ABI of the escrow it forwards to. + + +| Operation | Signature | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `authorize` | `authorize(PaymentInfo paymentInfo, uint256 amount, address tokenCollector, bytes collectorData)` | +| `charge` | `charge(PaymentInfo paymentInfo, uint256 amount, address tokenCollector, bytes collectorData, uint256 feeAmount, address feeReceiver)` | +| `capture` | `capture(PaymentInfo paymentInfo, uint256 amount, uint256 feeAmount, address feeReceiver)` | +| `void` | `void(PaymentInfo paymentInfo)` | +| `refund` | `refund(PaymentInfo paymentInfo, uint256 amount, address tokenCollector, bytes collectorData)` | + + +### `"delegated"` — facilitator is the operator + +The facilitator is itself the operator and calls the escrow directly with the escrow ABI. `extra.captureAuthorizer` is an address the facilitator controls and submits from. It MAY be an EOA or a smart-contract account (for example an ERC-4337 account that is `msg.sender` via a user operation). + +When `extra.receiverAuthorizer` is a non-zero address, the facilitator relays collect and lifecycle operations. What the server gives up is enforcement: nothing onchain requires an authorizer signature, checks it against the one the server holds, or stops a capture the server never asked for, so the facilitator's own verification is the only gate. A server choosing `"delegated"` with a receiver authorizer is trusting the facilitator to relay exactly what the client payload and its own authorizer signed, and nothing else. That trust is bounded by the escrow's client-side guarantees — the client-signed maximum, the fee bounds, and `reclaim` after the capture deadline — but within those bounds it is trust, not proof. + +When `extra.receiverAuthorizer` is absent or the zero address, `"delegated"` is authorize-only: the facilitator may relay `authorize`, but MUST reject `charge` and `payload.type` of `"capture"`, `"void"`, or `"refund"`. Later lifecycle operations run out of band under the operator's own rules (for example a self-facilitating server that submits capture from the same smart-contract account through its own API). + +### `"custom"` — collect-only relay, lifecycle out of band + +`extra.captureAuthorizer` is a contract that is `PaymentInfo.operator`. It MUST expose the escrow ABI's collect entry points (`authorize` and `charge`) as permissionless — any caller, including the facilitator, MAY invoke them — and each MUST forward to the escrow. The facilitator relays only that collect call; it has no other way onto the operator, so an access-controlled collect entry point makes the kind unusable. + +`"custom"` is identified by its entry in the facilitator's operator allowlist and by exposing those collect wrappers, not by the mere presence of bytecode. A smart-contract account the facilitator submits from is `"delegated"`, even though it has deployed code. + +For the client, the payment ends where the protocol ends: it signs once, the collect settles, and it has paid. Everything after that is between the server and the operator — merchant, arbiter, payer, or any other party the operator's policy allows calls the operator or its periphery directly, with whatever ABI and authentication that operator defines, typically through the operator's own SDK. The facilitator is not involved in or aware of that call path. + +Because lifecycle is out of band, the operator MAY impose additional rules — time locks, freeze windows, role-gated capture or void, arbitration, streaming release, or a surface that does not match `capture`/`void`/`refund` — without changing the escrow, the facilitator, or the scheme. Those rules are neither relayed nor validated by the facilitator. -Servers accepting auth-capture payments advertise with scheme `auth-capture`: +### Validation before relaying + +At verification time the facilitator branches on `extra.operatorType` and applies the rules of the branch it lands in: + +- **`"custom"`** — `extra.captureAuthorizer` exposes the permissionless collect wrappers and is admitted by the facilitator's operator allowlist (see [`/supported`](#supported)), otherwise `invalid_auth_capture_evm_operator_type_mismatch` or `invalid_auth_capture_evm_operator_not_admitted`. `extra.policy` is absent or the zero address. The payload is a collect settle (`authorize` or `charge` per `extra.paymentFlow`), never a lifecycle payload. +- **`"policy"`** — the appendix's rules apply, in [Future operator type: `policy`](#future-operator-type-policy). A facilitator that does not implement that type MUST reject it with `invalid_auth_capture_evm_unsupported_operator_type` rather than treat it as one of the other two. +- **default** — the value MUST be `"delegated"` or absent, otherwise `invalid_auth_capture_evm_unsupported_operator_type`. `extra.captureAuthorizer` MUST be an address the facilitator controls and submits from, otherwise `invalid_auth_capture_evm_operator_not_admitted`. That lookup against its own submitters is the whole test: the facilitator calls the escrow directly here, so an address it does not control is refused whether or not it is somebody else's operator contract. `extra.policy` is absent or the zero address. Without a non-zero `receiverAuthorizer` the payload is a collect settle, not a lifecycle payload. + +### Operator fields per type + +`extra.captureAuthorizer` is the payment's **authority** — the only address the escrow accepts as `msg.sender` — and `extra.receiverAuthorizer` is its **consent**, the EIP-712 signer of the operations the client did not authorize. Three `extra` fields carry the operator model, and only these three differ by type; absent is read as the zero address throughout. + + +| `extra` field | `"delegated"` | `"custom"` | `"policy"` | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `captureAuthorizer` | REQUIRED — an address the facilitator controls and submits from | REQUIRED — the operator contract | REQUIRED — the operator contract | +| `receiverAuthorizer` | OPTIONAL for `authorize`; REQUIRED and non-zero for `charge` and relayed lifecycle, with the facilitator the only thing that checks the signature | OPTIONAL for `authorize`; REQUIRED and non-zero for `charge`, whose signature the facilitator checks before relaying | REQUIRED, non-zero — the operator verifies its signature onchain | +| `policy` | UNUSED — MUST be absent or zero | UNUSED — MUST be absent or zero | OPTIONAL — zero selects the signature-only operator, non-zero names the policy contract | + + +Every other `extra` field means the same thing in all three, as its [`extra` entry](#extra-fields) gives it. + +## PaymentRequirements ```json { @@ -30,13 +88,17 @@ Servers accepting auth-capture payments advertise with scheme `auth-capture`: "extra": { "name": "USDC", "version": "2", - "captureAuthorizer": "0xCaptureAuthorizerAddress", + "authCaptureEscrow": "0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB", + "captureAuthorizer": "0xOperatorAddress", + "operatorType": "custom", + "receiverAuthorizer": "0xReceiverAuthorizerAddress", + "policy": "0x0000000000000000000000000000000000000000", + "paymentFlow": "escrow", "captureDeadline": 1740758554, "refundDeadline": 1741276954, - "minFeeBps": 0, - "maxFeeBps": 1000, + "minFeeBps": 100, + "maxFeeBps": 100, "feeRecipient": "0xFeeRecipientAddress", - "autoCapture": false, "assetTransferMethod": "eip3009" } } @@ -44,39 +106,59 @@ Servers accepting auth-capture payments advertise with scheme `auth-capture`: } ``` -### `extra` Fields +### `extra` fields + + +| Field | Required | Type | Description | +| --------------------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | Yes | `string` | EIP-712 token-domain name (e.g. `"USDC"`). Used for ERC-3009 signing only. | +| `version` | Yes | `string` | EIP-712 token-domain version (e.g. `"2"`). Not the commerce-payments deployment. | +| `authCaptureEscrow` | No | `address` | Canonical `AuthCaptureEscrow` this payment is collected into. Selects the [commerce-payments deployment](#commerce-payments-deployments): the v1.1 escrow (default when omitted) or the v1.0 escrow. New servers SHOULD write the v1.1 address out. Any other value is `invalid_auth_capture_evm_extra`. The matching collectors are not carried on `extra`. | +| `captureAuthorizer` | Yes | `address` | The operator, committed onchain as `PaymentInfo.operator`. | +| `receiverAuthorizer` | Per type | `address` | Signer of every `authorizerSignature` on facilitator-relayed `charge` and lifecycle settles, required as [Operator fields per type](#operator-fields-per-type) gives it. MUST be non-zero for every `charge` and every relayed `capture` / `void` / `refund`. Non-zero turns [salt binding](#payment-identity) on. MAY equal `captureAuthorizer`. | +| `policy` | Per type | `address` | Policy contract governing the payment, permitted as [Operator fields per type](#operator-fields-per-type) gives it. A non-zero value turns [salt binding](#payment-identity) on alongside `receiverAuthorizer`. See [Future operator type: `policy`](#future-operator-type-policy). | +| `captureDeadline` | Yes | `uint48` | Absolute Unix seconds; capture must occur before this. Onchain `authorizationExpiry`. | +| `refundDeadline` | Yes | `uint48` | Absolute Unix seconds; refunds are allowed until this. Onchain `refundExpiry`. | +| `feeRecipient` | Yes | `address` | Fee recipient, onchain `feeReceiver`. | +| `minFeeBps` | Yes | `uint16` | Fee floor in basis points; `0` for none. Onchain `PaymentInfo.minFeeBps` in both deployments. The submitted `charge` / `capture` fee is not this field: v1.1 submits `feeAmount`, v1.0 submits `feeBps`. | +| `maxFeeBps` | Yes | `uint16` | Fee ceiling in basis points. Onchain `PaymentInfo.maxFeeBps` in both deployments. | +| `paymentFlow` | No | `"escrow"` \| `"authorization"` | Which lifecycle applies, and with it whether the client's payload settles as `authorize` or `charge`. Default `"escrow"`. New servers SHOULD write `"escrow"` out. A server that captures automatically after its own `authorize` is still `"escrow"`; `"authorization"` means the client's payload itself settles as a terminal `charge`. | +| `captureMode` | No | `"sync"` \| `"deferred"` | Escrow-only resource-server choice for when the post-resource finalize runs. Default `"sync"`: the after-handler `/settle` relays `capture` or `void`. `"deferred"` skips that settle so the server captures later from durable state. MUST NOT be set when `paymentFlow` is `"authorization"`, and MUST be `"deferred"` on a collect-only route — `operatorType: "custom"`, or `"delegated"` with a zero `receiverAuthorizer` — per [Sync and async finalize](#sync-and-async-finalize). | +| `operatorType` | No | `"delegated"` \| `"custom"` | Kind of `extra.captureAuthorizer`: an address the facilitator controls and submits from (`"delegated"`, EOA or smart-contract account) or a contract with permissionless collect and an out-of-band lifecycle surface (`"custom"`). Default `"delegated"`. `"policy"` is reserved for the appendix's future type. | +| `assetTransferMethod` | No | `"eip3009"` \| `"permit2"` | Which canonical token collector the client authorizes. Default `"eip3009"`. A server MAY list several `accepts[]` entries differing only here, so clients can pick the method matching their token approvals. The collector address is not carried on `extra`; it is the collector of the [resolved deployment](#commerce-payments-deployments) for the chosen method. | + + +Where a description above names an onchain field, that is the `AuthCaptureEscrow` struct field the value becomes; the [PaymentInfo struct](#paymentinfo-struct) appendix gives the full derivation. + +## Commerce-payments deployments + +`AUTH_CAPTURE_ESCROW_ADDRESS`, `EIP3009_TOKEN_COLLECTOR_ADDRESS`, `PERMIT2_TOKEN_COLLECTOR_ADDRESS`, and `OPERATOR_REFUND_COLLECTOR_ADDRESS` name one canonical CREATE2 set. Two sets are specified. OPTIONAL `extra.authCaptureEscrow` selects between them; the collector addresses are never on `extra`. -| Field | Required | Type | Description | -| :-------------------- | :------- | :----------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `name` | Yes | `string` | EIP-712 token-domain name (e.g., `"USDC"`). Used for ERC-3009 signing only. | -| `version` | Yes | `string` | EIP-712 token-domain version (e.g., `"2"`). | -| `captureAuthorizer` | Yes | `address` | Address authorized to authorize/capture/void/refund/charge. Committed on-chain as `PaymentInfo.operator`. | -| `captureDeadline` | Yes | `uint48` | Absolute Unix seconds — capture must occur before this. Encoded as `authorizationExpiry`. | -| `refundDeadline` | Yes | `uint48` | Absolute Unix seconds — refunds allowed until this. Encoded as `refundExpiry`. | -| `feeRecipient` | Yes | `address` | Fee recipient (committed on-chain as `PaymentInfo.feeReceiver`). Set to `address(0)` to let the captureAuthorizer specify any non-zero recipient at capture/charge time. | -| `minFeeBps` | Yes | `uint16` | Minimum fee in basis points (the fee floor the captureAuthorizer must take). `0` = no minimum. | -| `maxFeeBps` | Yes | `uint16` | Maximum fee in basis points (the cap on the captureAuthorizer's fee). | -| `autoCapture` | No | `bool` | `true` → facilitator calls `charge()` (atomic). `false` → `authorize()` (two-phase). Default: `false`. | -| `assetTransferMethod` | No | `"eip3009" \| "permit2"` | Which token collector to use. Default: `"eip3009"`. A server MAY list multiple `accepts[]` entries with different `assetTransferMethod` values so clients can pick the method matching their token approvals. | +Treat an omitted `extra.authCaptureEscrow` as the v1.1 escrow. A facilitator MUST reject any other address with `invalid_auth_capture_evm_extra`. New servers SHOULD write the v1.1 address out. A server that still collects into the v1.0 escrow MUST publish that v1.0 address so a client that defaults to v1.1 does not sign a nonce and collector for the wrong set. -### Spec → on-chain field name mapping -The wire-format extra uses spec-level field names. The on-chain `PaymentInfo` struct keeps canonical Solidity names so the EIP-712 typehash matches the AuthCaptureEscrow contract byte-for-byte. +| Constant | v1.1 (default) | v1.0 | +| ----------------------------------- | ---------------------------------------------- | -------------------------------------------------- | +| `AUTH_CAPTURE_ESCROW_ADDRESS` | `0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB` | `0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff` | +| `EIP3009_TOKEN_COLLECTOR_ADDRESS` | `0xEA902B37036bcb4944577ec2101ABdEDF56EbD28` | `0x0E3dF9510de65469C4518D7843919c0b8C7A7757` | +| `PERMIT2_TOKEN_COLLECTOR_ADDRESS` | `0x1aacb38b16a1a8709e80746825E53A0C9Cae9b70` | `0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26` | +| `OPERATOR_REFUND_COLLECTOR_ADDRESS` | `0x6a1ADdEEb4bD9c5811a613e20c172b6CE61A4aaB` | `0x934907bffd0901b6A21e398B9C53A4A38F02fa5d` | -| Wire (`extra`) | On-chain (`PaymentInfo`) | -| :----------------------------------- | :----------------------- | -| `captureAuthorizer` | `operator` | -| `captureDeadline` | `authorizationExpiry` | -| `refundDeadline` | `refundExpiry` | -| `feeRecipient` | `feeReceiver` | -| (derived: `now + maxTimeoutSeconds`) | `preApprovalExpiry` | -## PaymentPayload +The client's `signatureNonce` commits to the resolved escrow, and `authorization.to` / `permit2Authorization.spender` MUST be the matching collector. `PaymentInfo` is the same struct in both deployments, including `minFeeBps` / `maxFeeBps`. What differs is the submitted fee on `charge` and `capture`: v1.1 takes an absolute `feeAmount` in atomic units; v1.0 takes `feeBps`. See [Fee system](#fee-system). `PERMIT2_ADDRESS` is the canonical [Uniswap Permit2 contract](https://docs.uniswap.org/contracts/v4/deployments) in both cases. -The payload carries the signature and the client-generated `salt`. The facilitator reconstructs the full `PaymentInfo` from `extra` + `salt` + payer + top-level requirements (`payTo`, `asset`, `amount`). +## Client payment payload + +The client signs one token authorization and sends it with a fresh random 32-byte value. From that plus the payment requirements, the facilitator reconstructs the whole `PaymentInfo` with no stored state of its own, field by field as the [PaymentInfo struct](#paymentinfo-struct) appendix sets out. Whether the payload settles as an `authorize` or a `charge` follows from `extra.paymentFlow`, so the client names no operation of its own. + +The collect payload always carries `salt`, which is `PaymentInfo.salt`. It is **unbound** when `receiverAuthorizer` and `policy` are both absent or zero, and **bound** when either is non-zero, in which case it **adds** `saltNonce`, the client's random contribution to the commitment in `salt`. See [Payment identity](#payment-identity). + +`salt` and `saltNonce` are 0x-prefixed 32-byte hex strings on the wire, zero-padded to their full width, and are the `uint256` those 32 bytes denote wherever the scheme hashes or encodes them. The same spelling applies to `paymentInfo.salt` on a [lifecycle payload](#lifecycle-payloads). Pinning one spelling matters because the value is also a database key for async servers: `"0x1"` and `"0x00…01"` are one `uint256` but two distinct strings. ### EIP-3009 (default) +Unbound (scheme v1.0 wire): + ```json { "x402Version": 2, @@ -97,18 +179,32 @@ The payload carries the signature and the client-generated `salt`. The facilitat } ``` -**Field derivation (EIP-3009):** +Bound — same envelope, `salt` is the commitment and `saltNonce` is added beside it: + +```json +{ + "payload": { + "authorization": { "...": "..." }, + "signature": "0x2d6a...571c", + "salt": "0x1f0e...9c3a", + "saltNonce": "0x0000000000000000000000000000000000000000000000000000000000000abc" + } +} +``` + + +| Payload field | Derived from | +| --------------------------- | ---------------------------------------------------------------------------------------------------- | +| `authorization.from` | Client's own address | +| `authorization.to` | `EIP3009_TOKEN_COLLECTOR_ADDRESS` of the [resolved deployment](#commerce-payments-deployments) | +| `authorization.value` | `requirements.amount` | +| `authorization.validAfter` | `0` — the token collector hardcodes the lower bound | +| `authorization.validBefore` | `now + requirements.maxTimeoutSeconds`, which is also `PaymentInfo.preApprovalExpiry` | +| `authorization.nonce` | The payment's `signatureNonce`, see [Payment identity](#payment-identity) | +| `salt` | Always. `PaymentInfo.salt`: the client's random 32 bytes when unbound, or the keccak commitment when bound | +| `saltNonce` | Bound only, **added**. Fresh 32 random bytes, one of the values hashed into that commitment; see [Payment identity](#payment-identity) | +| EIP-712 domain | `{ name, version }` from `extra`; `chainId` from `network`; `verifyingContract = requirements.asset` | -| Payload field | Derived from | -| :-------------------------- | :---------------------------------------------------------------------------------------------------------- | -| `authorization.from` | Client's own address | -| `authorization.to` | `EIP3009_TOKEN_COLLECTOR_ADDRESS` (universal constant) | -| `authorization.value` | `requirements.amount` | -| `authorization.validAfter` | `0` (the token collector hardcodes the lower bound) | -| `authorization.validBefore` | `now + requirements.maxTimeoutSeconds` (also used as `preApprovalExpiry` when reconstructing `PaymentInfo`) | -| `authorization.nonce` | Payer-agnostic `PaymentInfo` hash — see [Nonce Derivation](#nonce-derivation-both-methods) | -| `salt` | Fresh `bytes32` generated client-side per signing call | -| EIP-712 domain | `{ name, version }` from `extra`; `chainId` from `network`; `verifyingContract = requirements.asset` | ### Permit2 @@ -134,164 +230,658 @@ The payload carries the signature and the client-generated `salt`. The facilitat } ``` -**Field derivation (Permit2):** -| Payload field | Derived from | -| :-------------------------------------- | :---------------------------------------------------------------------------------------------------------- | -| `permit2Authorization.from` | Client's own address | -| `permit2Authorization.permitted.token` | `requirements.asset` | -| `permit2Authorization.permitted.amount` | `requirements.amount` | -| `permit2Authorization.spender` | `PERMIT2_TOKEN_COLLECTOR_ADDRESS` (universal constant) | -| `permit2Authorization.nonce` | `uint256(payerAgnosticPaymentInfoHash)` — see [Nonce Derivation](#nonce-derivation-both-methods) | -| `permit2Authorization.deadline` | `now + requirements.maxTimeoutSeconds` (also used as `preApprovalExpiry` when reconstructing `PaymentInfo`) | -| `salt` | Fresh `bytes32` generated client-side per signing call | -| EIP-712 domain | Canonical Permit2 contract; `chainId` from `network` | +| Payload field | Derived from | +| --------------------------------------- | ---------------------------------------------------------------------------------------- | +| `permit2Authorization.from` | Client's own address | +| `permit2Authorization.permitted.token` | `requirements.asset` | +| `permit2Authorization.permitted.amount` | `requirements.amount` | +| `permit2Authorization.spender` | `PERMIT2_TOKEN_COLLECTOR_ADDRESS` of the [resolved deployment](#commerce-payments-deployments) | +| `permit2Authorization.nonce` | The payment's `signatureNonce` as a `uint256`, see [Payment identity](#payment-identity) | +| `permit2Authorization.deadline` | `now + requirements.maxTimeoutSeconds`, which is also `PaymentInfo.preApprovalExpiry` | +| `salt` / `saltNonce` | `salt` always (`PaymentInfo.salt`); `saltNonce` added when bound, as for EIP-3009 | +| EIP-712 domain | Canonical Permit2 contract; `chainId` from `network` | + + +No witness struct is needed: the receiver is bound through the deterministic nonce. + +### Payment identity + +Treat absent `receiverAuthorizer` and `policy` as the zero address. `payload.salt` is always `PaymentInfo.salt`. When the bind is on, `payload.saltNonce` is **added** as the client's contribution to it: + +``` +# unbound — both addresses zero +payload.salt = PaymentInfo.salt # client's random 32 bytes +# no saltNonce + +# bound — receiverAuthorizer and/or policy non-zero +payload.saltNonce # client's random 32 bytes +payload.salt = PaymentInfo.salt + = uint256(keccak256(abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce))) + +SALT_BINDING_TYPEHASH = keccak256("x402AuthCaptureSaltBinding(address receiverAuthorizer,address policy,uint256 saltNonce)") +``` + +Unbound is the v1.0 collect path: the wire `salt` is the onchain salt. Bound is a commitment to the two addresses that govern facilitator-relayed `charge` and lifecycle. The keccak is one-way — `PaymentInfo.salt` does not contain those addresses — so the payload **adds** `saltNonce` beside `salt`, never in place of it. Reopening the commitment means recomputing the hash over all four encoded words, taking the addresses from `extra` and the nonce from `payload.saltNonce`, and requiring the result to equal `payload.salt`, and, for `"policy"`, passing `saltNonce` into the operator ABI. Reconstructing `PaymentInfo` from `payload.salt` matches the signature nonce; this extra check is what proves the embedding, so a collected bound payment cannot be re-pointed at a different authorizer or policy. Async servers MUST persist `saltNonce` with `paymentInfo` whenever the bind is on; `paymentInfo` alone is not enough to re-check the bind or to call the policy operator. + +`SALT_BINDING_TYPEHASH` is a domain tag, and it is REQUIRED. Without it the commitment would be an untagged three-word keccak, the same shape as the `signatureNonce` encoding below, and nothing would establish that a value produced as one can never be read as the other. It also names the derivation, so the four encoded words — the typehash, both addresses, and the nonce — are the whole preimage and there is no shorter one. Encode them as four left-padded 32-byte words with `abi.encode`, never `abi.encodePacked`. + +Two hashes derive from the struct, and they are not interchangeable: + +``` +payerAgnosticHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfo with payer = address(0))) +signatureNonce = keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, payerAgnosticHash)) + +paymentInfoHash = AuthCaptureEscrow.getHash(paymentInfo) +``` + +`AUTH_CAPTURE_ESCROW_ADDRESS` here is the [resolved deployment](#commerce-payments-deployments)'s escrow. `signatureNonce` is the nonce inside the client's token authorization, computed with `payer` zeroed and every other field holding the value it will have onchain. `paymentInfoHash` is the escrow's canonical payment identifier, computed over the real payer; it keys the escrow's `paymentState` and appears in every authorizer signature. Both commit to the chain id and the escrow address, so neither crosses chains or deployments. + +The client's random 32 bytes (`payload.salt` when unbound, `payload.saltNonce` when bound) are what keep `signatureNonce` fresh: two payers signing concurrently, or one payer buying the same resource twice, produce distinct nonces with no collision risk. -**No witness** — the merchant address is bound through the deterministic nonce, not a separate witness struct. +### Smart-wallet signatures -### Nonce Derivation (both methods) +A payer MAY be a contract account, so a facilitator MUST verify all three signature forms rather than ECDSA alone: a 65-byte ECDSA signature recovered against the payload's `from`, an ERC-1271 signature checked with `isValidSignature` on a deployed wallet, and an EIP-6492 envelope for a wallet that does not exist yet. An EIP-6492 signature carries the wallet's deployment bytecode: the facilitator extracts the inner signature to verify it, and the `ERC6492SignatureHandler` inside the token collector deploys the wallet during settlement. -The signature nonce is the payer-agnostic `PaymentInfo` hash. Payer is zeroed; everything else is the values that will appear on-chain. +A facilitator MUST submit `collectorData` with the wrapper intact. The token collector runs `ERC6492SignatureHandler` over those bytes, executes the preparation call through the neutral Multicall3 sender, and only then passes the inner signature to the token or Permit2; unwrapping first would drop the deployment. The bytes stay untrusted and payer-controlled, so a custom operator MUST forward them unchanged. +A counterfactual payer — EIP-6492 envelope, no code at the payer address yet — has no `isValidSignature` to call, so a facilitator MUST defer the signature check to the collect simulation, where the collector deploys the wallet before the token validates. An unknown preparation target can burn arbitrary gas in the facilitator's transaction, so it MUST allowlist the targets it accepts, at verify as well as settle, and reject the rest with `invalid_auth_capture_evm_erc6492_factory_not_allowed`. An empty allowlist admits none. + +### Completing the payload for settlement + +- `authorize` **MUST collect the full** `requirements.amount`. The server adds no amount and no `authorizerSignature`: the client's token authorization is the consent for this settle. Collecting less is destructive rather than thrifty — that authorization is single-use, so a smaller collection consumes it and permanently caps the payment below the ceiling the client agreed to. +- `charge` **requires a non-zero** `receiverAuthorizer` and **may name any** `amount` greater than zero and at most `requirements.amount`, carried alongside the submitted fee and `feeReceiver` the escrow requires whenever funds are distributed, plus an `authorizerSignature` over that exact charge. Charging less than the maximum is safe because charge is terminal: the difference simply never leaves the payer. + +The charge case is the only one where the server adds fields to the client's payload. It leaves the client's own fields untouched and appends four, and the payload is always bound, because a receiver authorizer is what turns the bind on. The submitted fee field follows the [resolved deployment](#commerce-payments-deployments): `feeAmount` (atomic units, same spelling as `amount`) on v1.1, `feeBps` (`uint16`) on v1.0. A mixed group — `feeBps` on v1.1 extra, or `feeAmount` on v1.0 extra — is `invalid_auth_capture_evm_payload_format`. The default submitted fee on v1.1 is `feeAmount = amount * extra.minFeeBps / 10000`. + +```json +{ + "payload": { + "authorization": { "...": "..." }, + "signature": "0x2d6a...571c", + "salt": "0x1f0e...9c3a", + "saltNonce": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "amount": "750000", + "feeAmount": "7500", + "feeReceiver": "0xFeeRecipientAddress", + "authorizerSignature": "0x9b1c...44ef" + } +} +``` + + +| Added field | Type | Description | +| --------------------- | --------- | ------------------------------------------------------------------------------------ | +| `amount` | `uint256` | The amount to charge, greater than zero and at most `requirements.amount`. | +| `feeAmount` | `uint256` | Fee submitted with the call on v1.1, per [Fee system](#fee-system). On a v1.0 extra pin this field is `feeBps` (`uint16`) instead. | +| `feeReceiver` | `address` | Fee recipient submitted with the call, per [Fee system](#fee-system). | +| `authorizerSignature` | `bytes` | EIP-712 `Charge` signature by `extra.receiverAuthorizer` over exactly these values. | + + +All four are absent on `authorize`, where the client's payload settles as it arrived. + +Under `escrow` the choice of amount is postponed rather than lost: `capture` and `refund` each name their own amount and each may repeat, bounded by the hold and by the amount already captured. Holding the full ceiling is therefore costless — `capture` takes only what is owed and `void` returns the rest — while a hold set too low can never be raised. + +## Authorizer signatures + +`authorizerSignature` is an EIP-712 signature by `extra.receiverAuthorizer` over the parameters of the operation being requested. Both ECDSA and ERC-1271 signatures are valid, so the authorizer MAY itself be a contract. It is required on every facilitator-relayed `charge`, whatever the amount, and on lifecycle settles for `"delegated"` (and `"policy"`). It is never required on `authorize`. + +The server chooses that authorizer: an address it owns — an EOA, or a contract so the signing key can rotate — or an address the facilitator advertises in [`/supported`](#supported), delegating authorization to it. Delegation applies wherever a payload carries `authorizerSignature`, `charge` and lifecycle alike: the server omits the field, and the facilitator produces the signature after authenticating the request out of band, rejecting it with `invalid_auth_capture_evm_unauthenticated_authorizer_request` when it cannot. The signature is then the facilitator's own and no longer evidence of the server's intent, so that authentication is what has to supply the evidence instead. + +### Domain + +Every operator type shares one domain, with the capture authorizer as `verifyingContract`: + +``` +{ name: "x402 Auth Capture Operator", version: "1", chainId, verifyingContract: extra.captureAuthorizer } +``` + +`verifyingContract` is the operator rather than one address for the whole scheme because a contract that verifies an EIP-712 signature binds its own address into the domain it checks against: an operator that does verify these digests onchain has its domain fixed by where it is deployed, and two such operators cannot share one. Neither `"delegated"` nor `"custom"` has an onchain verifier for facilitator-relayed calls, but both follow the same rule anyway, so that one formula covers every type and a signature is bound to its operator by the domain as well as by `paymentInfoHash`. + +### Types + +The two repeatable lifecycle operations carry the single-use element [`auth-capture`](./scheme_auth_capture.md#core-properties) requires by binding both escrow balances the authorizer expects to find. `Charge` and `Capture` include the submitted fee as the onchain argument: `uint256 feeAmount` on v1.1, `uint16 feeBps` on a v1.0 extra pin. + + +| Operation | Signed type (v1.1) | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `charge` | `Charge(bytes32 paymentInfoHash,uint256 amount,address tokenCollector,bytes32 collectorDataHash,uint256 feeAmount,address feeReceiver)` | +| `void` | `Void(bytes32 paymentInfoHash)` | +| `capture` | `Capture(bytes32 paymentInfoHash,uint256 amount,uint256 feeAmount,address feeReceiver,uint256 expectedCapturableAmount,uint256 expectedRefundableAmount)` | +| `refund` | `Refund(bytes32 paymentInfoHash,uint256 amount,address tokenCollector,uint256 expectedCapturableAmount,uint256 expectedRefundableAmount)` | + + +On a v1.0 extra pin, `Charge` and `Capture` use `uint16 feeBps` in place of `uint256 feeAmount`; `Void` and `Refund` are unchanged. The authorizer MUST sign the fee field that the escrow call will take. Converting `feeBps` to `feeAmount` at encode time on v1.1 is invalid: the digest would not cover the value that executes. + + +`collectorDataHash` is `keccak256(collectorData)`. `Refund` carries no funding address, because the escrow ABI has no such parameter: refund liquidity comes from `paymentInfo.operator` itself, as [Refund funding](#refund-funding) sets out. + +Replay across payments is impossible for any type, since `paymentInfoHash` commits to every `PaymentInfo` field — the operator, and when bound the authorizer and the policy among them. + +### Single-use enforcement + +Before relaying a `capture` or `refund`, the facilitator reads `AuthCaptureEscrow.paymentState(paymentInfoHash)` and MUST reject the request unless both signed expectations match what it finds: `capturableAmount == expectedCapturableAmount` and `refundableAmount == expectedRefundableAmount`. + +Binding both balances is required because `refundableAmount` alone is not monotonic: a capture after a refund can restore a refundable level an earlier refund signature was signed against. The pair `(capturableAmount, refundableAmount)` cannot recur once either balance has moved — after a refund, `capturable + refundable` falls and never recovers — so each signature is single-use. Partial and repeated captures each get their own signature against the snapshot they expect. No nonce is kept anywhere: the payment's own state is the replay key. + +For `operatorType: "delegated"` this check runs only at the facilitator, so onchain state can still change between the check and inclusion, making the guarantee best-effort. Repeating it onchain is one of the things the appendix's `"policy"` type buys. + +## Sync and async finalize + +Whether the post-resource finalize runs during the paid request or afterwards is a resource-server choice. This binding publishes that choice as OPTIONAL `extra.captureMode` (`"sync"` default, or `"deferred"`) so a route can carry it through to settle time. The field is meaningful only under `paymentFlow: "escrow"` and MUST NOT appear on an `"authorization"` route. + +- **Sync.** The in-request `/settle` after the resource runs is a `capture`, a partial `capture` with `void` of the remaining balance, or a `void`. No durable payment state is required. +- **Async.** That second in-request settle does not call the facilitator or broadcast a transaction. The server instead commits enough payment info into durable storage to author lifecycle settles later — at least `paymentInfo`, and when the bind is on the client `saltNonce`. + +A later facilitator-relayed `refund` always requires durable state, including after a sync finalize. Both patterns are open to the types that relay lifecycle: `"delegated"` with a non-zero `receiverAuthorizer`, and the appendix's `"policy"` type. + +The collect-only cases — `"custom"`, and `"delegated"` with no receiver authorizer — are async only, since lifecycle always runs out of band against the stored payment. A resource server MUST NOT publish `captureMode: "sync"` on one of those routes, and because `"sync"` is the default it MUST write `"deferred"` out rather than leave the field off. A route that gets this wrong strands the payment: the collect escrows the payer's funds, the after-handler `capture` or `void` is refused with `invalid_auth_capture_evm_lifecycle_not_relayed` since the facilitator relays no lifecycle for these types, and the hold then sits until the payer calls `reclaim` after the capture deadline. + +## Lifecycle payloads + +`capture`, `void`, and `refund` have no client payload to build on, so the resource server authors them outright and passes them to `POST /settle` with `payload.type` naming the operation. `payload.type` appears only on these three; nothing else in the scheme carries it. Each payload gives the payment as the exact `paymentInfo` struct the onchain call takes, rather than leaving the facilitator to reconstruct it. + +They apply only where the facilitator relays lifecycle: `operatorType: "delegated"` with a non-zero `receiverAuthorizer`, and the appendix's `"policy"` type. The collect-only cases run lifecycle out of band and MUST NOT submit these payloads to the facilitator. + +Two fields share the name `feeReceiver` without being the same thing: `paymentInfo.feeReceiver` is the recipient the client committed to, and `payload.feeReceiver` is the one submitted with the call. See [Fee system](#fee-system) for when they may differ. + +Lifecycle payloads are bind-on: they carry `saltNonce` next to `paymentInfo.salt` (the commitment) so the facilitator can reopen the embedding. + +### `capture` + +```json +{ + "x402Version": 2, + "accepted": { "scheme": "auth-capture", "...": "..." }, + "payload": { + "type": "capture", + "paymentInfo": { + "operator": "0xOperatorAddress", + "payer": "0xPayerAddress", + "receiver": "0xReceiverAddress", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "maxAmount": "1000000", + "preApprovalExpiry": 1740675754, + "authorizationExpiry": 1740758554, + "refundExpiry": 1741276954, + "minFeeBps": 100, + "maxFeeBps": 100, + "feeReceiver": "0xFeeRecipientAddress", + "salt": "0x1f0e...9c3a" + }, + "saltNonce": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "amount": "750000", + "feeAmount": "7500", + "feeReceiver": "0xFeeRecipientAddress", + "expectedCapturableAmount": "1000000", + "expectedRefundableAmount": "0", + "authorizerSignature": "0x9b1c...44ef", + "voidAuthorizerSignature": "0x7a2d...18c0" + } +} +``` + +`expectedCapturableAmount` and `expectedRefundableAmount` are REQUIRED. + +`voidAuthorizerSignature` is OPTIONAL and present only for a sync partial close-out: when set, this single `/settle` performs `capture` and then `void` on the remaining hold. It is the `Void` digest for the same `paymentInfoHash`, verified like a standalone `void`. The capture leg uses the same single-use balance check as a capture-only settle; the following `void` needs no replay key of its own. Omit it for a capture-only settle (full capture, or a partial that leaves the hold for later). + +### `void` + +```json +{ + "x402Version": 2, + "accepted": { "scheme": "auth-capture", "...": "..." }, + "payload": { + "type": "void", + "paymentInfo": { "...": "..." }, + "saltNonce": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "authorizerSignature": "0x9b1c...44ef" + } +} ``` -paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfoWithZeroPayer)) -nonce = keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash)) + +### `refund` + +```json +{ + "x402Version": 2, + "accepted": { "scheme": "auth-capture", "...": "..." }, + "payload": { + "type": "refund", + "paymentInfo": { "...": "..." }, + "saltNonce": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "amount": "250000", + "expectedCapturableAmount": "250000", + "expectedRefundableAmount": "750000", + "authorizerSignature": "0x9b1c...44ef" + } +} ``` -Freshness is enforced by `salt`: each signing call generates a fresh `bytes32` salt, so two payers signing concurrently produce distinct nonces with no collision risk. +`expectedCapturableAmount` and `expectedRefundableAmount` are REQUIRED. The token collector is always `OPERATOR_REFUND_COLLECTOR_ADDRESS`, so it is not carried on the wire. + +## Verification + +### Client payment payload -## Verification Logic +The operation is `authorize` or `charge` according to `extra.paymentFlow`, which defaults to `"escrow"` when omitted. A resource server first sends the client-authored payload to `/verify`; for `charge`, that initial payload omits the four server-authored completion fields. Before `/settle`, the server adds all four fields from [Completing the payload for settlement](#completing-the-payload-for-settlement). A facilitator MUST accept the raw form at `/verify`, but MUST require the completed form at `/settle`. It performs these checks in order: -The facilitator performs these checks in order: +1. **Shape guard**: the payload matches the EIP-3009 or Permit2 shape above, with `signature` and `salt` always present, and `saltNonce` present if and only if the bind is on. A completed `charge` carries `amount`, the deployment's fee field (`feeAmount` on v1.1, `feeBps` on a v1.0 extra pin), `feeReceiver`, and `authorizerSignature` together — the last omitted only when the authorizer is delegated to the facilitator — while the raw `/verify` form and every `authorize` carry none of the four. Any partial group is invalid, a mixed fee field for the resolved deployment is invalid, and `/settle` rejects a `charge` that still has none. A server that nonce-probes for v1.0 clients is the one exception to the `saltNonce` rule, accepting a bound payment whose `saltNonce` is absent under [Compatibility with v1.0](#compatibility-with-v10). +2. **Scheme match**: `requirements.scheme` and `payload.accepted.scheme` are both `auth-capture`. +3. **Network match**: `payload.accepted.network === requirements.network`, in `eip155:` form. +4. **Extra validation**: `requirements.extra` carries `captureAuthorizer`, `captureDeadline`, `refundDeadline`, `feeRecipient`, `minFeeBps`, `maxFeeBps`, `name`, and `version`; `extra.authCaptureEscrow` is absent or one of the two canonical escrow addresses in [Commerce-payments deployments](#commerce-payments-deployments); resolved `paymentFlow` is one of the two defined values, and the fee fields satisfy [Fee system](#fee-system). +5. **Operator**: `extra.operatorType`, `extra.policy`, and `extra.receiverAuthorizer` pass the validation in [Operator types](#validation-before-relaying). +6. **Method routing**: `extra.assetTransferMethod` (default `"eip3009"`) matches the payload shape. +7. **Deadline ordering**: `refundDeadline >= captureDeadline`, `now + maxTimeoutSeconds <= captureDeadline` so the whole window a client may sign for fits inside the hold, `captureDeadline > now + 6s` (the 6s figure is a floor; implementations MAY use a larger skew), and `validBefore` (EIP-3009) or `deadline` (Permit2) `<= captureDeadline`. +8. **Time window**: `validBefore` / `deadline` `> now + 6s` (floor, as in step 7), and `validAfter <= now` (EIP-3009 only). +9. **Collector match**: `authorization.to === EIP3009_TOKEN_COLLECTOR_ADDRESS`, or `permit2Authorization.spender === PERMIT2_TOKEN_COLLECTOR_ADDRESS`, using the collectors of the [resolved deployment](#commerce-payments-deployments). Do not read a collector from `extra`. +10. **Token match**: `permitted.token === requirements.asset` (Permit2 only; EIP-3009 binds the token through its signing domain). +11. **Client signature**: verify the signature over the `ReceiveWithAuthorization` or `PermitTransferFrom` digest against the `from` address the payload names, which is the payer. ECDSA, ERC-1271, and EIP-6492 signatures are all valid and a facilitator MUST accept all three, as [Smart-wallet signatures](#smart-wallet-signatures) sets out. For a counterfactual payer this check moves to the simulation in step 15; the allowlist gate on the preparation target replaces it here. +12. **Amount and fee**: `authorization.value` or `permitted.amount` equals `requirements.amount`; `authorize` settles that full amount. A completed `charge` requires `0 < payload.amount <= requirements.amount`, and its submitted fee (`feeAmount` on v1.1, `feeBps` on a v1.0 extra pin) and `feeReceiver` satisfy [Fee system](#fee-system). For raw `/verify`, use `requirements.amount` and the default fee terms as provisional simulation inputs. +13. **Nonce match**: reconstruct `PaymentInfo` from `extra`, `payload.salt` as `PaymentInfo.salt`, the payer, and the requirements; recompute `signatureNonce` and assert it equals the nonce on the wire. When bound, also require `payload.salt == uint256(keccak256(abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce)))`. This transitively enforces equality on every field encoded in `PaymentInfo` — receiver, token, deadlines, fee bounds, fee recipient, operator, and when bound the receiver authorizer and policy — so none of them needs a check of its own. +14. **Authorizer signature** (completed `charge` only): the `Charge` signature recovers to the required non-zero `extra.receiverAuthorizer`, whatever the amount, or the facilitator produces it under the delegation rule in [Authorizer signatures](#authorizer-signatures). Skip for `authorize` and the raw `/verify` form; `/settle` cannot use that raw form. +15. **Simulate** the settlement call and require success. Raw charge verification simulates the provisional full-amount charge from step 12; settlement re-verification simulates the exact authorizer-signed charge it will submit. -1. **Type guard**: Verify payload matches one of `Eip3009Payload` or `Permit2Payload` (must include `signature` and `salt`). -2. **Scheme match**: `requirements.scheme === "auth-capture"` and `payload.accepted.scheme === "auth-capture"`. -3. **Network match**: `payload.accepted.network === requirements.network` and format is `eip155:`. -4. **Extra validation**: `requirements.extra` contains all required fields (`captureAuthorizer`, `captureDeadline`, `refundDeadline`, `feeRecipient`, `minFeeBps`, `maxFeeBps`, `name`, `version`). -5. **Method routing**: `extra.assetTransferMethod` (default `"eip3009"`) matches the payload shape. -6. **Deadline ordering**: `refundDeadline >= captureDeadline`, `captureDeadline > now + 6s`, and `payload.validBefore` (EIP-3009) / `payload.deadline` (Permit2) `<= captureDeadline`. -7. **Time window**: `payload.deadline / validBefore > now + 6s` (not expired) and `validAfter <= now` (active, EIP-3009 only). -8. **Spender / collector match**: `payload.to === EIP3009_TOKEN_COLLECTOR_ADDRESS` (EIP-3009) or `payload.spender === PERMIT2_TOKEN_COLLECTOR_ADDRESS` (Permit2). -9. **Token match**: `payload.permitted.token === requirements.asset` (Permit2 only — EIP-3009 binds via signing domain). -10. **Signature verify**: Recover signer from EIP-712 (`ReceiveWithAuthorization` or `PermitTransferFrom`); must match `payer`. -11. **Amount**: `authorization.value` (EIP-3009) or `permit2Authorization.permitted.amount` (Permit2) matches `requirements.amount`. -12. **Nonce match**: Reconstruct `PaymentInfo` from extra + payload.salt + payer + requirements; recompute payer-agnostic hash; assert it matches the wire nonce. This transitively enforces equality on every field encoded in `PaymentInfo` (receiver, token, deadlines, fee bounds, feeRecipient), so individual field-by-field checks for those values are unnecessary. -13. **Simulate** `AUTH_CAPTURE_ESCROW.authorize(...)` or `.charge(...)` to ensure success. +Step 15 is materially heavier on RPC for `operatorType: "custom"` than for `"delegated"`, which simulates a single escrow call: the custom outcome assertions in [`/supported`](#supported) need the operator's token store and the pre- and post-call state around the relay. -### EIP-6492 Support +### Lifecycle payloads -For smart wallet clients, the signature may be EIP-6492 wrapped (containing deployment bytecode). The facilitator extracts the inner ECDSA signature for verification. The on-chain `ERC6492SignatureHandler` in the token collector handles wallet deployment during settlement. +Lifecycle payloads apply only to `operatorType: "delegated"` with a non-zero `receiverAuthorizer`. For `operatorType: "custom"`, and for `"delegated"` without a receiver authorizer, the facilitator MUST reject the request with `invalid_auth_capture_evm_lifecycle_not_relayed` without further checks. -## Settlement Logic +`capture` and `void` additionally require `paymentFlow: "escrow"`. The `"authorization"` flow settles the client's payload as a terminal `charge` and places no hold, so there is nothing to capture or release, and a payload naming either MUST be rejected with `invalid_auth_capture_evm_payload_type`. `refund` applies to both flows. -1. **Re-verify** the payload (catches expired/invalid payloads before spending gas). -2. **Determine function**: `extra.autoCapture === true ? "charge" : "authorize"`. -3. **Resolve collector**: `EIP3009_TOKEN_COLLECTOR_ADDRESS` or `PERMIT2_TOKEN_COLLECTOR_ADDRESS` (per `assetTransferMethod`). -4. **Encode `collectorData`**: raw ERC-3009 signature, or ABI-encoded Permit2 signature. -5. **Call escrow**: `AUTH_CAPTURE_ESCROW.(paymentInfo, amount, tokenCollector, collectorData)`. -6. **Wait for receipt**: 60s timeout. -7. **Return result**: tx hash, network, payer. +For an admitted lifecycle payload, the facilitator repeats the scheme, network, extra, and operator checks (2 through 5 above), and then: + +1. **Operator match**: `payload.paymentInfo.operator === extra.captureAuthorizer`. +2. **Salt binding**: `payload.paymentInfo.salt === uint256(keccak256(abi.encode(SALT_BINDING_TYPEHASH, extra.receiverAuthorizer, extra.policy, payload.saltNonce)))`. There is no client signature here to enforce this transitively, so it is an explicit check. +3. **Requirements match**: every remaining `paymentInfo` field equals what `extra` and the top-level requirements dictate. +4. **Authorizer signature**: the operation's signature recovers to `extra.receiverAuthorizer`, or the facilitator produces it under the delegation rule in [Authorizer signatures](#authorizer-signatures). +5. `voidAuthorizerSignature` (capture only): if present, it MUST recover to `extra.receiverAuthorizer` over the `Void` digest, and it falls under the same delegation rule as step 4. It MUST NOT appear on `void` or `refund` payloads. +6. **Operation preconditions**, read from `AuthCaptureEscrow.paymentState(paymentInfoHash)` and `paymentInfo`: + + | Operation | Preconditions | + | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | + | `capture` | `now < authorizationExpiry`; `0 < amount <= capturableAmount`; fee parameters per [Fee system](#fee-system); the single-use balance check | + | `void` | `capturableAmount > 0` | + | `refund` | `now < refundExpiry`; `0 < amount <= refundableAmount`; the single-use balance check; refund liquidity available per [Refund funding](#refund-funding) | + + When `voidAuthorizerSignature` is present, apply the `capture` row to `payload.amount`, and require `amount < capturableAmount` so a hold remains for `void` (a full capture omits the field). Simulation covers both legs. + +7. **Simulate** the settlement call and require success. + +`reclaim` is out of scope for the facilitator: the escrow restricts it to `paymentInfo.payer`, so it can only be called by the client and needs no operator ABI. Out-of-band lifecycle is likewise out of scope, submitted straight to the operator. + +## Settlement + +1. **Re-verify** the payload, requiring the completed charge form and catching anything that expired or was consumed since verification. +2. **Resolve the target**: the resolved `AUTH_CAPTURE_ESCROW_ADDRESS` for `operatorType: "delegated"`, `extra.captureAuthorizer` for `"custom"`. +3. **Encode the call** for the operation, which is `payload.type` on a lifecycle payload and `extra.paymentFlow`'s implied `authorize` or `charge` on a client payload. For `authorize` and `charge`, resolve the collector from `extra.assetTransferMethod` and the [resolved deployment](#commerce-payments-deployments), and set `collectorData` to the raw ERC-3009 signature or the ABI-encoded Permit2 signature; `authorize` passes `requirements.amount`, and `charge` passes the authorizer-signed payload's `amount`, submitted fee (`feeAmount` on v1.1, `feeBps` on a v1.0 extra pin), and `feeReceiver` with no conversion between the two. For `refund`, the collector is `OPERATOR_REFUND_COLLECTOR_ADDRESS` with empty `collectorData`. +4. **Capture-and-void**: when `payload.type` is `"capture"` and `voidAuthorizerSignature` is present, drive both legs from this single `/settle`: encode `capture` as above, then `void` with that signature. Submit them in one transaction when the target allows — any batched path the facilitator controls for `"delegated"` — and otherwise as two transactions still under this one request. If a race empties the hold between capture and void, skip `void` and treat the settle as capture-only success. +5. **Submit**, wait up to 60 s for the receipt, and confirm the transaction succeeded onchain. +6. **Return** the transaction hash, network, payer, and the amount settled (the captured amount; void releases the rest without changing that figure). + +## Refund funding + +Facilitator-relayed refunds use `OperatorRefundCollector`, which pulls the refunded tokens from `paymentInfo.operator`. What that implies differs per type: + +- `"delegated"` — the operator is an address the facilitator submits from, which would make the facilitator a source of value. A facilitator MUST reject `type: "refund"` for a `"delegated"` operator unless it has an explicit out-of-band funding agreement with the receiver, and MUST authorize the request against that agreement rather than against the authorizer signature, which here amounts to the receiver approving a spend of someone else's money. +- `"custom"` — the facilitator does not relay refunds. How a `"custom"` operator sources refund liquidity is that operator's business, settled out of band. + +## `/supported` + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "auth-capture", + "network": "eip155:8453", + "extra": { + "captureAuthorizer": "0xFacilitatorSubmitterAddress", + "receiverAuthorizer": "0xFacilitatorAuthorizerAddress", + "feeRecipient": "0xFeeRecipientAddress", + "minFeeBps": 100, + "maxFeeBps": 100, + "operators": [ + { "address": "*", "operatorType": "custom" } + ] + } + } + ], + "extensions": [], + "signers": { "eip155:*": ["0xFacilitatorSignerAddress"] } +} +``` + +- `signers` is the authoritative set of addresses the facilitator submits transactions from. A smart-contract account the facilitator submits from MAY appear here. It is not where a server reads `extra.captureAuthorizer`: `signers` is response-level and family-keyed, while submitter sets are not in general uniform across the chains a facilitator serves. +- `extra.captureAuthorizer` is the address the facilitator commits to submitting from on this network. It is REQUIRED unless `extra.operators` is non-empty, which is the relay-only case where the facilitator never acts as operator itself: a kind without it admits nothing but `operatorType: "delegated"`, and a server has no other source for the submitter address, so a kind carrying neither is malformed. A server using that kind for `operatorType: "delegated"` copies it verbatim into its payment requirements. A facilitator that advertises a `captureAuthorizer` MUST submit `"delegated"` calls from it. A facilitator MAY vary which submitter it advertises between `/supported` responses to spread load across its keys. Because the address is committed onchain as `PaymentInfo.operator` and is the only address the escrow accepts for that payment's `capture`, `void`, and `refund`, a facilitator MUST remain able to submit from an address it has advertised until every payment that used it has passed its `refundDeadline`. Retiring a submitter earlier strands outstanding holds: nothing can capture or void them, and the payer recovers the funds only by calling `reclaim` after the capture deadline. A facilitator that rotates submitters and also relays `type: "refund"` for `"delegated"` MUST fund and approve **every** address in the rotation, since `OperatorRefundCollector` pulls with `safeTransferFrom(token, PaymentInfo.operator, tokenStore, amount)`. +- `extra.receiverAuthorizer` is OPTIONAL: an address the facilitator will sign with on a server's behalf, which a server MAY adopt in place of an authorizer it owns under [Authorizer signatures](#authorizer-signatures). A facilitator MUST NOT advertise one unless it can authenticate those requests out of band, for example with SIWX, a JWT, or an API credential bound at payment creation. A payment commits to both addresses — `receiverAuthorizer` through the salt, `captureAuthorizer` as `PaymentInfo.operator` — so one that delegates both is captive to this facilitator until its `refundDeadline`, and a server changing facilitators SHOULD let outstanding payments finalize first. +- `extra.feeRecipient`, `extra.minFeeBps`, and `extra.maxFeeBps` are OPTIONAL facilitator fee terms. A server using that supported kind MUST copy all three verbatim into its payment requirements. Equal bounds fix the fee and are what a facilitator SHOULD advertise; unequal bounds ask the server to grant free choice up to `maxFeeBps`, since nothing onchain holds the facilitator to a narrower value. Omission means the facilitator claims no fee, and a `"delegated"` server publishes zero bounds per [Fee system](#fee-system). +- `extra.operators` is an OPTIONAL allowlist of the contract operators the facilitator will relay for, each entry pairing an address with the type it is admitted as. Omitted or `[]` admits no contract operator at all, leaving only `operatorType: "delegated"` with the facilitator's own submitter. `"address": "*"` admits every contract of that type; the wildcard MUST be written out, and an empty list MUST NOT be read as one. A facilitator MAY advertise the wildcard, but it is not recommended: admission then extends to contract code the facilitator has never reviewed, bounded only by the requirements below. + +A facilitator that offers `"custom"` is relaying into contract code and MUST: + +1. **Cap the gas.** The simulated and submitted calls MUST use a gas limit chosen by the facilitator, so an operator cannot drain its gas budget. +2. **Assert the outcome, not the absence of a revert.** Before relaying, the facilitator MUST simulate the exact call and confirm that the canonical escrow of the [resolved deployment](#commerce-payments-deployments) emitted the expected `PaymentAuthorized` or `PaymentCharged` event with the expected payment hash and arguments (`feeAmount` on v1.1 `PaymentCharged`, `feeBps` on v1.0), that `paymentState` made the exact intended before-to-after transition, and that the net token movements match the operation. A successful top-level operator call alone is insufficient. No token movement may originate from a facilitator-controlled address. + +The simulation RPC MUST expose nested-call logs and enough pre- and post-call state to establish those conditions, whether through state diffs or stateful follow-up reads. A facilitator without access to those capabilities MUST NOT advertise or accept `"custom"`. After the transaction is confirmed, the facilitator MUST apply the same outcome checks to the actual receipt and resulting onchain state before reporting settlement success; a successful receipt status alone is insufficient. + +Admission is by address, not by code: an operator behind a proxy can change implementation after it is admitted. A facilitator SHOULD prefer immutable operators, and MUST apply the assertions above on every relay rather than resting on a review. ## Error Codes -The auth-capture scheme uses the standard x402 error codes plus these scheme-specific codes: - -### Verification Errors - -| Error Code | Description | -| :---------------------------------- | :-------------------------------------------------------------------------------- | -| `invalid_payload_format` | Payload doesn't match `Eip3009Payload` or `Permit2Payload`. | -| `unsupported_scheme` | Scheme is not `auth-capture`. | -| `network_mismatch` | Payload network doesn't match requirements. | -| `invalid_network` | Network format is not `eip155:`. | -| `invalid_auth_capture_extra` | Extra is missing required fields. | -| `unsupported_asset_transfer_method` | `assetTransferMethod` is not `"eip3009"` or `"permit2"`. | -| `payload_method_mismatch` | Payload shape doesn't match `assetTransferMethod`. | -| `capture_deadline_expired` | `captureDeadline <= now + 6s`. | -| `invalid_deadline_ordering` | Deadlines violate `now + maxTimeoutSeconds <= captureDeadline <= refundDeadline`. | -| `authorization_expired` | EIP-3009 `validBefore` (or Permit2 `deadline`) `<= now + 6s`. | -| `authorization_not_yet_valid` | EIP-3009 `validAfter > now`. | -| `invalid_auth_capture_signature` | Signature verification failed. | -| `amount_mismatch` | Authorization value doesn't match `requirements.amount`. | -| `token_collector_mismatch` | `to` / `spender` doesn't match the canonical collector for the method. | -| `token_mismatch` | Permit2 `permitted.token` doesn't match `requirements.asset`. | -| `nonce_mismatch` | Wire nonce doesn't match the recomputed payer-agnostic PaymentInfo hash. | -| `insufficient_balance` | Payer balance is less than required amount. | -| `simulation_failed` | Settlement simulation reverted with an unmapped error. | +The scheme uses the standard x402 error codes plus the following. Every reason this binding defines is namespaced `invalid_auth_capture_evm_*`; standard reasons it also returns, such as `invalid_network`, keep their canonical names. + +### Verification errors + + +| Error Code | Description | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid_auth_capture_evm_payload_format` | Payload matches neither the EIP-3009 nor the Permit2 shape, omits `salt`, omits `saltNonce` when bound (unless probing), carries it unbound, or carries the wrong fee field for the resolved deployment (`feeBps` on v1.1 extra, or `feeAmount` on a v1.0 extra pin). | +| `invalid_auth_capture_evm_payload_type` | A lifecycle payload's `payload.type` is missing or is not `"capture"`, `"void"`, or `"refund"`. | +| `invalid_auth_capture_evm_void_authorizer_signature` | `voidAuthorizerSignature` is present on a non-capture payload, or does not recover to `extra.receiverAuthorizer` over the `Void` digest. | +| `invalid_auth_capture_evm_void_remainder_full_capture` | `voidAuthorizerSignature` is present but `amount` equals the full `capturableAmount`, leaving nothing to void. | +| `invalid_auth_capture_evm_unsupported_payment_flow` | Resolved `paymentFlow` is neither `"escrow"` nor `"authorization"`, or `extra` carries the removed `autoCapture: true`. | +| `invalid_auth_capture_evm_scheme` | Scheme is not `auth-capture`. | +| `invalid_auth_capture_evm_network_mismatch` | Payload network does not match requirements. | +| `invalid_network` | Network format is not `eip155:`. | +| `invalid_auth_capture_evm_extra` | Extra is missing required fields, `authCaptureEscrow` is not a canonical escrow address, or its fee fields violate [Fee system](#fee-system). | +| `invalid_auth_capture_evm_missing_receiver_authorizer` | `extra.receiverAuthorizer` is absent or zero for a `charge` or facilitator-relayed lifecycle settle. | +| `invalid_auth_capture_evm_unsupported_operator_type` | `extra.operatorType` is an unknown value, or a type the facilitator does not implement. | +| `invalid_auth_capture_evm_policy` | `extra.policy` does not fit the declared operator type: non-zero where the zero address is required, or not a policy contract where one is expected. | +| `invalid_auth_capture_evm_lifecycle_not_relayed` | A lifecycle payload (`capture`, `void`, or `refund`) was submitted for `operatorType: "custom"`, or for `"delegated"` without a receiver authorizer. | +| `invalid_auth_capture_evm_operator_type_mismatch` | `extra.captureAuthorizer` does not expose the collect wrappers `"custom"` / `"policy"` requires. | +| `invalid_auth_capture_evm_operator_not_admitted` | The operator is not on the facilitator's allowlist, or `"delegated"` names an address it does not control and submit from. | +| `invalid_auth_capture_evm_operator_mismatch` | `paymentInfo.operator` does not match `extra.captureAuthorizer`. | +| `invalid_auth_capture_evm_salt_binding_mismatch` | Bound `paymentInfo.salt` is not the salt derived from `receiverAuthorizer`, `policy`, and `saltNonce`. | +| `invalid_auth_capture_evm_authorizer_signature` | The authorizer signature does not recover to `extra.receiverAuthorizer`. | +| `invalid_auth_capture_evm_unauthenticated_authorizer_request` | The authorizer is delegated to the facilitator and the `charge` or lifecycle request carries no out-of-band authentication. | +| `invalid_auth_capture_evm_unexpected_payment_state` | Observed `capturableAmount` or `refundableAmount` differs from the signed expectation. | +| `invalid_auth_capture_evm_refund_funding_unavailable` | No refund liquidity path exists for the declared operator type. | +| `invalid_auth_capture_evm_unsupported_asset_transfer_method` | `assetTransferMethod` is neither `"eip3009"` nor `"permit2"`. | +| `invalid_auth_capture_evm_payload_method_mismatch` | Payload shape does not match `assetTransferMethod`. | +| `invalid_auth_capture_evm_capture_deadline_expired` | `captureDeadline <= now + 6s` (floor), or a capture was attempted after it. | +| `invalid_auth_capture_evm_refund_deadline_expired` | A refund was attempted at or after `refundDeadline`. | +| `invalid_auth_capture_evm_deadline_ordering` | Deadlines violate `now + maxTimeoutSeconds <= captureDeadline <= refundDeadline`. | +| `invalid_auth_capture_evm_authorization_expired` | EIP-3009 `validBefore` or Permit2 `deadline` is `<= now + 6s` (floor). | +| `invalid_auth_capture_evm_authorization_not_yet_valid` | EIP-3009 `validAfter > now`. | +| `invalid_auth_capture_evm_signature` | Client signature verification failed. | +| `invalid_auth_capture_evm_erc6492_factory_not_allowed` | The payer is counterfactual and its EIP-6492 preparation target is not on the facilitator's allowlist. | +| `invalid_auth_capture_evm_amount_mismatch` | Authorization value does not match `requirements.amount`. | +| `invalid_auth_capture_evm_token_collector_mismatch` | `to` or `spender` is not the expected collector for the method. | +| `invalid_auth_capture_evm_token_mismatch` | Permit2 `permitted.token` does not match `requirements.asset`. | +| `invalid_auth_capture_evm_nonce_mismatch` | Wire nonce does not match the recomputed `signatureNonce`. | +| `invalid_auth_capture_evm_insufficient_balance` | Payer balance is below the required amount. | +| `invalid_auth_capture_evm_simulation_failed` | Simulation reverted with an unmapped error. | + ### Typed simulation reverts -If the simulate call reverts with an `AuthCaptureEscrow` custom error declared in the call's ABI, the facilitator decodes it via `BaseError.walk()` + `ContractFunctionRevertedError` and surfaces a stable reason instead of the opaque `simulation_failed` fallback: - -| Custom error | `invalidReason` | -| :------------------------------ | :------------------------------------ | -| `AfterPreApprovalExpiry` | `authorization_expired` | -| `InvalidExpiries` | `invalid_deadline_ordering` | -| `ExceedsMaxAmount` | `amount_mismatch` | -| `PaymentAlreadyCollected` | `payment_already_collected` | -| `TokenCollectionFailed` | `token_collection_failed` | -| `InvalidCollectorForOperation` | `invalid_collector` | -| `InvalidSender` | `invalid_capture_authorizer` | -| `ZeroAmount` / `AmountOverflow` | `amount_mismatch` / `amount_overflow` | -| `FeeBpsOverflow` | `invalid_fee_bps` | -| `InvalidFeeBpsRange` | `invalid_fee_bps_range` | -| `FeeBpsOutOfRange` | `fee_bps_out_of_range` | -| `ZeroFeeReceiver` | `zero_fee_receiver` | -| `InvalidFeeReceiver` | `invalid_fee_receiver` | -| `AfterAuthorizationExpiry` | `capture_deadline_expired` | -| `InsufficientAuthorization` | `insufficient_authorization` | -| `ZeroAuthorization` | `zero_authorization` | - -### Settlement Errors - -| Error Code | Description | -| :--------------------- | :------------------------------------------------ | -| `verification_failed` | Re-verification before settlement failed. | -| `transaction_reverted` | On-chain transaction reverted after confirmation. | +When simulation reverts with a custom error declared in the call's ABI, the facilitator decodes it and surfaces a stable reason instead of the opaque `invalid_auth_capture_evm_simulation_failed` fallback. + +`AuthCaptureEscrow` errors: + + +| Custom error | `invalidReason` | +| ------------------------------- | --------------------------------------------------------------------------------------- | +| `AfterPreApprovalExpiry` | `invalid_auth_capture_evm_authorization_expired` | +| `InvalidExpiries` | `invalid_auth_capture_evm_deadline_ordering` | +| `ExceedsMaxAmount` | `invalid_auth_capture_evm_amount_mismatch` | +| `PaymentAlreadyCollected` | `invalid_auth_capture_evm_payment_already_collected` | +| `TokenCollectionFailed` | `invalid_auth_capture_evm_token_collection_failed` | +| `InvalidCollectorForOperation` | `invalid_auth_capture_evm_collector` | +| `InvalidSender` | `invalid_auth_capture_evm_operator_mismatch` | +| `ZeroAmount` / `AmountOverflow` | `invalid_auth_capture_evm_amount_mismatch` / `invalid_auth_capture_evm_amount_overflow` | +| `FeeBpsOverflow` | `invalid_auth_capture_evm_fee_bps` | +| `InvalidFeeBpsRange` | `invalid_auth_capture_evm_fee_bps_range` | +| `FeeBpsOutOfRange` | `invalid_auth_capture_evm_fee_bps_out_of_range` | +| `FeeAmountOutOfRange` | `invalid_auth_capture_evm_fee_bps_out_of_range` | +| `ZeroFeeReceiver` | `invalid_auth_capture_evm_zero_fee_receiver` | +| `InvalidFeeReceiver` | `invalid_auth_capture_evm_fee_receiver` | +| `AfterAuthorizationExpiry` | `invalid_auth_capture_evm_capture_deadline_expired` | +| `InsufficientAuthorization` | `invalid_auth_capture_evm_insufficient_authorization` | +| `ZeroAuthorization` | `invalid_auth_capture_evm_zero_authorization` | +| `AfterRefundExpiry` | `invalid_auth_capture_evm_refund_deadline_expired` | +| `RefundExceedsCapture` | `invalid_auth_capture_evm_refund_exceeds_capture` | + + +### Settlement errors + + +| Error Code | Description | +| ----------------------------------------------- | ------------------------------------------------ | +| `invalid_auth_capture_evm_verification_failed` | Re-verification before settlement failed. | +| `invalid_auth_capture_evm_transaction_reverted` | Onchain transaction reverted after confirmation. | + ## Appendix -### PaymentInfo Struct (canonical Solidity — wire-level field names map per the table above) +### PaymentInfo struct + +The struct keeps its canonical Solidity names rather than the names the same values carry on the wire, so that its EIP-712 typehash matches the `AuthCaptureEscrow` contract byte-for-byte. Every field, and where the facilitator gets it: ```solidity struct PaymentInfo { address operator; // = extra.captureAuthorizer - address payer; // payload-derived + address payer; // = the payload's `from`, verified against the client's signature address receiver; // = requirements.payTo address token; // = requirements.asset uint120 maxAmount; // = requirements.amount - uint48 preApprovalExpiry; // = now + maxTimeoutSeconds (client-derived) + uint48 preApprovalExpiry; // = now + requirements.maxTimeoutSeconds, chosen client-side uint48 authorizationExpiry; // = extra.captureDeadline uint48 refundExpiry; // = extra.refundDeadline - uint16 minFeeBps; - uint16 maxFeeBps; + uint16 minFeeBps; // = extra.minFeeBps + uint16 maxFeeBps; // = extra.maxFeeBps address feeReceiver; // = extra.feeRecipient - uint256 salt; // = payload.salt (client-generated, fresh per request) + uint256 salt; // = payload.salt. Unbound: client's random bytes. Bound: keccak256(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce) } ``` -### Expiry Ordering +### Expiry ordering + +The escrow enforces `preApprovalExpiry <= authorizationExpiry <= refundExpiry`, and each expiry gates a different operation: + + +| Expiry | Enforced at | Effect | +| --------------------- | -------------------------- | ------------------------------------ | +| `preApprovalExpiry` | `authorize()` / `charge()` | Blocks collecting the client's funds | +| `authorizationExpiry` | `capture()` | Blocks capture; enables `reclaim()` | +| `refundExpiry` | `refund()` | Blocks refunds | + + +### Fee system + +`PaymentInfo.minFeeBps` and `PaymentInfo.maxFeeBps` are the client-signed bounds in both deployments, each in the range 0–10,000. They stay in basis points so each `charge` or `capture` scales its own `amount`: 100 bps of 750000 is not 100 bps of 1000000. `authorize` has no fee argument. + +The submitted fee on `charge` and `capture` is the onchain argument, and that is what the authorizer signs: + +- **v1.1 (default).** The call takes `uint256 feeAmount` in atomic units. The escrow requires `amount * minFeeBps / 10000 <= feeAmount <= amount * maxFeeBps / 10000`. Integer division is the escrow's. The default submitted value is `amount * minFeeBps / 10000`. +- **v1.0 extra pin.** The call takes `uint16 feeBps`. The escrow requires `minFeeBps <= feeBps <= maxFeeBps` and computes `feeAmount = amount * feeBps / 10000` itself. + +In both cases the remainder after the fee goes to the receiver. If `PaymentInfo.feeReceiver` is non-zero, the submitted `feeReceiver` must equal it; if it is `address(0)`, any non-zero address is accepted. A zero `feeReceiver` with a non-zero submitted fee (`feeAmount` or `feeBps`) reverts. + +Those are the guarantee. Who holds the submitter to a narrower value differs per operator type: + +- `"delegated"` — the escrow's range and a non-zero `feeReceiver` are the only limits. An `authorizerSignature` over the submitted fee tells a conformant facilitator what to submit, but nothing onchain reads that signature, so the server's exposure is `maxFeeBps` whether the bind is on or off. This is the trust bound [`"delegated"`](#delegated--facilitator-is-the-operator) already states for every operation. +- `"custom"` — the operator contract is the enforcement point, so the submitter's discretion is whatever that operator leaves unchecked, within the escrow's range. +- `"policy"` — the operator verifies the authorizer's digest onchain, so the signed fee and `feeReceiver` are what executes. That binds every submitter. It binds the authorizer's own choice only where the server holds the authorizer key, or where the policy constrains the fee through the fee and `feeReceiver` its hooks receive. + +A server therefore MUST read its own published bounds, not any signature, as what it has agreed to: + +- **No agreed fee.** `minFeeBps == maxFeeBps == 0`, which is required unless the server has agreed fee terms with the party that ends up choosing the value: the facilitator under `"delegated"`, the operator under `"custom"`. Nothing can then be taken from any payment. The submitted fee MUST be `0`. +- **Agreed fixed fee.** `minFeeBps == maxFeeBps` at the agreed value with a non-zero `feeRecipient`, which servers SHOULD prefer: the escrow admits exactly one fee to exactly one address, so the outcome needs no trust. On v1.1 the submitted `feeAmount` MUST be `amount * that value / 10000` and the submitted `feeReceiver` MUST be `extra.feeRecipient`. On a v1.0 extra pin the submitted `feeBps` MUST be that value. For `"delegated"` these are the facilitator's `/supported` fee terms copied verbatim; for `"custom"` they come from the server's agreement with the operator. +- **Agreed range.** `minFeeBps < maxFeeBps` is permitted only where the server has agreed that range with the same party, and it grants that party free choice inside it. Publishing a range is granting the discretion, not reserving it. +- **Zero fee receiver.** A zero `PaymentInfo.feeReceiver` lets the submitter name any non-zero recipient, so under `"delegated"` and `"custom"` it MUST come with `minFeeBps == maxFeeBps == 0`. A zero fee on the submitted call is not sufficient, since the submitter can send another value within the signed range. Under `"policy"` the authorizer's signature over `feeReceiver` is verified onchain, which supplies the missing constraint. + +### Compatibility with v1.0 + +This section is the x402 scheme v1.0 collect path (unbound salt, `autoCapture`), not the commerce-payments contract deployment. For the latter see [Commerce-payments deployments](#commerce-payments-deployments) and the paragraph below. + +The unbound collect path is byte-identical to scheme v1.0: `extra.captureAuthorizer`, payload `salt`, `PaymentInfo.salt = payload.salt`. Clients MUST ignore unrecognized `extra` keys. New servers that omit `receiverAuthorizer` and `policy` remain payable by scheme-v1.0 clients that still target the same commerce deployment. New clients that treat omitted `receiverAuthorizer` / `policy` as unbound remain able to pay scheme-v1.0 servers on that deployment. + +v1.0's `extra.autoCapture` is removed, and `paymentFlow` is the only flow selector. `autoCapture: false` agrees with the default and needs no handling, but a facilitator that receives `autoCapture: true` MUST reject with `invalid_auth_capture_evm_unsupported_payment_flow` rather than fall through to `"escrow"`, because settling a hold where the server asked for a terminal `charge` is a different onchain outcome and no later error would report it. + +Turning the bind on (non-zero `receiverAuthorizer` or `policy`) changes `signatureNonce` and requires `saltNonce` beside `salt`. A scheme-v1.0 client then emits only `salt` (the random value, not the hash), so verification fails with `invalid_auth_capture_evm_payload_format` or `invalid_auth_capture_evm_nonce_mismatch` unless the server probes. + +A server that turns the bind on while still serving scheme-v1.0 clients MAY nonce-probe: if `saltNonce` is absent, treat `payload.salt` as the nonce, recompute `signatureNonce` for the raw value and for the commitment over it, and accept whichever matches. It MUST persist the matching `uint256` (and the nonce, if any) for the life of the payment so later `capture` / `void` / `refund` reuse that salt and do not re-hash. A probed scheme-v1.0 payment is unbound onchain even though extra advertised an authorizer — the authorizer is then an HTTP check only. Pin `receiverAuthorizer` and `policy` per (`captureAuthorizer`, chain) and never rotate them mid-flight. + +A scheme-v1.0 client also ignores `extra.authCaptureEscrow` and signs the collectors and escrow it was built with (the v1.0 set). It cannot pay a server that collects into the v1.1 escrow: `signatureNonce` and `authorization.to` / `permit2Authorization.spender` will not match. A new client pays a v1.0 commerce deployment if and only if `extra.authCaptureEscrow` is that v1.0 escrow; omitted extra selects v1.1. Charge and capture payloads on that pin keep `feeBps`; the v1.1 wire uses `feeAmount`. + + +### Future operator type: `policy` + +`"delegated"` and `"custom"` sit at opposite ends of a trade-off. `"delegated"` with a receiver authorizer relays the whole lifecycle but rests on trusting the facilitator; `"custom"` needs no trust in the facilitator, but everything past collect also leaves the protocol. A third type, `operatorType: "policy"`, is a planned addition that closes the gap, and it buys two things: + +1. **Trustless relay.** The operator contract, not the facilitator, is what gates the escrow. It checks the payment's binding, verifies the receiver authorizer's EIP-712 signature onchain for `charge` and lifecycle, and compares the signed balances against `paymentState` before calling the escrow. That is also what makes the signed fee and `feeReceiver` enforceable rather than advisory, so a range may be published without granting the submitter discretion over it (see [Fee system](#fee-system)). The facilitator's HTTP checks stop being the thing that protects the server: a request the facilitator would refuse also reverts when anyone else submits it directly, and a request the facilitator relays cannot deviate from what the authorizer signed. +2. **Capture and void stay in the protocol.** They remain ordinary relayed `/settle` calls even when a contract enforces conditions on them, so the server keeps the gasless, RPC-free path it has under `"delegated"` without the trust. The conditions come from a separate policy contract consulted through read-only hooks, which is what makes relaying into unreviewed policy code safe for the facilitator. + +Everything this type needs on the wire already exists: `extra.operatorType: "policy"`, `extra.policy` naming the policy contract, and `PaymentInfo.salt` committing to it. Because the salt commits to the policy, the client's own signature commits to it too — a payer knows which rules govern its money before it pays, and a collected payment cannot be re-pointed at a different policy afterwards. `extra.policy` MAY be the zero address here, which selects the signature-only operator of step 1 below; `receiverAuthorizer` MUST be non-zero, so the bind is on. No payload, digest, or `extra` field changes. As with `"delegated"`, `authorize` needs no authorizer signature — only the salt-binding trailing parameters. + +That authorizer MAY be delegated to the facilitator here as anywhere else, and `extra.policy` is what decides the cost. With the zero address of step 1 the operator only re-checks a signature the facilitator itself produced, which buys `"delegated"`'s trust at the price of a contract call. With a policy attached the conditions bind the authorizer too, so a server that holds no key still keeps an enforced envelope `"delegated"` has no way to offer. + +#### Operator ABI + +The operator implements the escrow ABI with trailing parameters appended. The facilitator relays collect and lifecycle operations the same way, targeting `extra.captureAuthorizer` in both cases. + + +| Operation | Trailing parameters added to the escrow ABI | +| ----------- | -------------------------------------------------------------------------------------------------------------- | +| `authorize` | `address authorizer, address policy, uint256 saltNonce` | +| `charge` | `address authorizer, address policy, uint256 saltNonce, bytes authorizerSignature` | +| `void` | `address authorizer, address policy, uint256 saltNonce, bytes authorizerSignature` | +| `capture` | `address authorizer, address policy, uint256 saltNonce, ExpectedBalances expected, bytes authorizerSignature` | + + +`ExpectedBalances` is `(uint256 capturableAmount, uint256 refundableAmount)` — the balances the authorizer expects in `paymentState` when the call executes. The EIP-712 digests name those members `expectedCapturableAmount` and `expectedRefundableAmount`. + +A `"custom"` / `"policy"` mixup on a collect call needs no separate check, because it fails closed: the misdeclared type encodes a selector the target does not implement, so simulation reverts before any gas is spent. + +#### Step 1: the operator as a signature wrapper + +At its simplest the operator adds nothing but the checks the facilitator was trusted to run, moved onchain. `capture` is the operation where all of them appear at once: + +```solidity +function capture( + IAuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + uint256 feeAmount, + address feeReceiver, + address authorizer, + address policy, + uint256 saltNonce, + ExpectedBalances calldata expected, + bytes calldata authorizerSignature +) external nonReentrant { + // operator == address(this), authorizer != 0, policy == 0, + // and salt == keccak256(SALT_BINDING_TYPEHASH, authorizer, policy, saltNonce) + bytes32 paymentInfoHash = _checkBinding(paymentInfo, authorizer, policy, saltNonce); + _checkSignature( + authorizer, getCaptureDigest(paymentInfoHash, amount, feeAmount, feeReceiver, expected), authorizerSignature + ); + _checkExpectedBalances(paymentInfoHash, expected); + + ESCROW.capture(paymentInfo, amount, feeAmount, feeReceiver); +} +``` + +The salt check is what ties `authorizer` to this payment: an attacker cannot pass an authorizer of its own choosing, because `paymentInfo.salt` was fixed by the client's signature. `_checkExpectedBalances` reads `paymentState` and reverts unless both balances equal the signed pair — the same [single-use rule](#single-use-enforcement), now enforced atomically with the call rather than best-effort ahead of it. The operator is permissionless by design: anyone may submit, and without a fresh authorizer signature nothing happens. + +#### Step 2: read-only policy hooks + +What the wrapper cannot express is *when* an operation is allowed — a cooldown before capture, a window in which void is still possible, a role that must sign off. Encoding any of that in the operator would mean a new operator contract per rule, each needing its own review and its own allowlist entry at every facilitator. + +Instead the rule lives in a separate contract, named by `extra.policy` and consulted through `ICaptureAuthorizer`, whose predicates are `view` and return a boolean: + +```solidity + bytes32 paymentInfoHash = _checkBinding(paymentInfo, authorizer, policy, saltNonce); + _checkSignature( + authorizer, getCaptureDigest(paymentInfoHash, amount, feeAmount, feeReceiver, expected), authorizerSignature + ); + if (!ICaptureAuthorizer(policy).authorizeCapture(paymentInfo, amount, feeAmount, feeReceiver, "")) { + revert AuthorizationDenied(); + } + _checkExpectedBalances(paymentInfoHash, expected); + + ESCROW.capture(paymentInfo, amount, feeAmount, feeReceiver); +``` + +This is a separate deployment with the same call ABI and a stricter binding: `_checkBinding` here requires `policy` to be non-zero and to advertise `ICaptureAuthorizer` through ERC-165, so a plain address cannot be passed off as a policy, while the step 1 operator requires `policy` to be zero. + +The hooks are read-only, and that is the point for the facilitator. A `view` predicate cannot move value, cannot re-enter the escrow, and cannot leave state behind; it can only say yes or no, with gas its sole cost. The operator's own checks are unconditional and run regardless of what the policy answers — the policy is consulted *in addition to* the signature and balance checks, never instead of them. That ordering is mandatory rather than stylistic: a policy that only gates *when* an operation is permissible would otherwise let a third party force a capture the moment the window opens, or force a void and deprive the server of the payment. + +One mutating hook is defined for policies that must record state when the hold is placed: `ICaptureLifecycle.onAuthorize(PaymentInfo)`, invoked only on `authorize`, only after the escrow call has succeeded, and only when the policy advertises the interface through ERC-165. + +#### Step 3: an example policy + +A policy is small. This one admits the `escrow` flow only and holds capture back until a cooldown past the pre-approval expiry has elapsed, giving the payer a guaranteed window in which the hold is untouchable and `void` is still the only outcome the server can force: + +```solidity +contract DelayedCapturePolicy is ICaptureAuthorizer, ERC165 { + uint48 public immutable COOLDOWN; + + constructor( + uint48 cooldown + ) { + COOLDOWN = cooldown; + } + + function authorizeCapture( + IAuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256, + uint256, + address, + bytes calldata + ) external view returns (bool) { + return block.timestamp >= uint256(paymentInfo.preApprovalExpiry) + COOLDOWN; + } + + // authorizeCharge returns false — a charge would settle instantly, defeating the delay. + // authorizeAuthorization and authorizeVoid return true. + + function supportsInterface( + bytes4 interfaceId + ) public view override returns (bool) { + return interfaceId == type(ICaptureAuthorizer).interfaceId || super.supportsInterface(interfaceId); + } +} +``` + +Because the policy address is in the salt, this promise is verifiable by the payer at signing time and immutable afterwards. The same shape covers freeze windows, role-gated capture or void, arbitration hooks, and oracle-conditioned release — each a small `view` contract rather than a new operator. + +#### Facilitator validation and errors + +Validation follows the `"custom"` rules — `extra.captureAuthorizer` exposes the collect wrappers and is admitted by the facilitator's allowlist — with two changes: `capture` and `void` payloads are relayed rather than rejected, and `extra.policy`, when non-zero, MUST have deployed code and advertise `ICaptureAuthorizer` through ERC-165. A `refund` payload is rejected with `invalid_auth_capture_evm_refund_funding_unavailable`, since the operator exposes no refund entry point. Admission works the same way, with an extra `/supported` entry: + +```json +{ "address": "0xOperatorAddress", "operatorType": "policy" } +``` + +The operator declares typed errors that a facilitator decodes from a reverted simulation: + + +| Custom error | `invalidReason` | +| --------------------------------- | ------------------------------------------------------ | +| `WrongOperator` | `invalid_auth_capture_evm_operator_mismatch` | +| `SaltMismatch` | `invalid_auth_capture_evm_salt_binding_mismatch` | +| `ZeroAuthorizer` | `invalid_auth_capture_evm_missing_receiver_authorizer` | +| `InvalidSignature` | `invalid_auth_capture_evm_authorizer_signature` | +| `UnexpectedPaymentState` | `invalid_auth_capture_evm_unexpected_payment_state` | +| `InvalidPolicy` / `NonZeroPolicy` | `invalid_auth_capture_evm_policy` | +| `AuthorizationDenied` | `invalid_auth_capture_evm_policy_denied` | + -The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry`. +### Contract addresses -| Expiry | Wire field | Enforced at | Effect | -| :-------------------- | :---------------- | :------------------------- | :---------------------------------- | -| `preApprovalExpiry` | derived | `authorize()` / `charge()` | Blocks settlement after this time | -| `authorizationExpiry` | `captureDeadline` | `capture()` | Blocks capture; enables `reclaim()` | -| `refundExpiry` | `refundDeadline` | `refund()` | Blocks refund requests | +The two canonical CREATE2 sets, and how `extra.authCaptureEscrow` selects between them, are in [Commerce-payments deployments](#commerce-payments-deployments). `PERMIT2_ADDRESS` is the canonical [Uniswap Permit2 contract](https://docs.uniswap.org/contracts/v4/deployments). -### Fee System +No operator or policy address is canonical, and none is named above. `extra.captureAuthorizer` and `extra.policy` are per-deployment values with no protocol-level meaning: a facilitator MUST NOT treat either as trusted because its bytecode matches one of the shapes sketched here, and admission goes through the [`/supported`](#supported) rules in every case. The Solidity in the appendix illustrates a future addition rather than audited or deployed code, and anyone building on it is responsible for reviewing, auditing, and deploying their own. -Fees are enforced on-chain by the escrow contract: +## Version History -- `minFeeBps` and `maxFeeBps` set by the client in `PaymentInfo` (0–10,000 bps) -- `feeBps` at capture/charge must fall within `[minFeeBps, maxFeeBps]` -- If `feeReceiver` (`extra.feeRecipient`) is set in `PaymentInfo`, actual `feeReceiver` at capture/charge must match -- If `feeReceiver` is `address(0)`, the caller can specify any non-zero address -- Fee distribution: `feeAmount = amount * feeBps / 10000`, remainder goes to receiver -### Canonical Addresses +| Version | Date | Changes | Authors | +| ------- | ---------- | ----------------------------------------------------------------------------------------------- | --------- | +| v1.1 | 2026-08-26 | Payment flow lifecycles, operator types and commerce-payments v1.1 | @phdargen | +| v1.0 | 2026-05-13 | Initial draft | @A1igator | -The `AUTH_CAPTURE_ESCROW_ADDRESS`, `EIP3009_TOKEN_COLLECTOR_ADDRESS`, and `PERMIT2_TOKEN_COLLECTOR_ADDRESS` constants resolve to the canonical [Base Commerce-Payments contracts](https://github.com/base/commerce-payments/releases/tag/v1.0.0). -The `PERMIT2_ADDRESS` constant resolves to the canonical [Uniswap Permit2 contract](https://docs.uniswap.org/contracts/v4/deployments). diff --git a/specs/schemes/exact/scheme_exact.md b/specs/schemes/exact/scheme_exact.md index d327d19ef9..864809c9c1 100644 --- a/specs/schemes/exact/scheme_exact.md +++ b/specs/schemes/exact/scheme_exact.md @@ -13,7 +13,11 @@ amount of funds they need to be transferred. ## Payment Flow -By default, `exact` uses the `authorization` payment flow (verify → resource → settle): the payment is verified before the resource executes and settled afterward. See [Payment Flow Models](../../x402-specification-v2.md) (section 6.1) in the core specification. +By default, `exact` uses the `authorization` payment flow (verify → resource → settle): the payment is verified before the resource executes and settled afterward. + +`exact` MAY also use the `upfront` payment flow (settle → resource → respond) when the resource needs on-chain finality before execution. Payload creation, verification checks, and settlement mechanics are unchanged; only resource-server ordering changes. Facilitator `/verify` is not invoked; `/settle` both validates and commits the payment. + +When the resolved flow is not `authorization`, `accepts[].extra.paymentFlow` MUST be `"upfront"`. Clients SHOULD prefer `authorization` when both flows are offered. See [Payment Flow Models](../../x402-specification-v2.md) (section 6.1) in the core specification. ## Critical Validation Requirements diff --git a/specs/schemes/exact/scheme_exact_svm.md b/specs/schemes/exact/scheme_exact_svm.md index 00ca08a041..87947f64fe 100644 --- a/specs/schemes/exact/scheme_exact_svm.md +++ b/specs/schemes/exact/scheme_exact_svm.md @@ -44,6 +44,8 @@ The protocol flow for `exact` on Solana is client-driven. 12. Upon successful on-chain settlement, a `SettlementResponse` is returned from the Sponsor to the Resource Server. 13. Resource Server grants the Client access to the resource in its response. +Resource servers with long-running handlers SHOULD use payment flow `upfront`, because under `authorization` the handler must complete before the signed transaction's blockhash expires (~60–90 seconds). + --- ## `PaymentRequirements` for `exact` diff --git a/specs/schemes/upto/scheme_upto.md b/specs/schemes/upto/scheme_upto.md index 76a999d5e6..202dae031d 100644 --- a/specs/schemes/upto/scheme_upto.md +++ b/specs/schemes/upto/scheme_upto.md @@ -12,6 +12,10 @@ This scheme is ideal for usage-based pricing models where the final cost is not - Bandwidth or data transfer metering (charge per byte transferred in a single request) - Dynamic compute pricing (charge based on actual resources consumed) +## Payment Flow + +`upto` cannot use `upfront` because the settled amount is known only after the route handler runs, so settlement cannot precede resource execution. + ## Core Properties (MUST) The `upto` scheme MUST enforce the following properties across ALL network implementations: diff --git a/specs/x402-specification-v2.md b/specs/x402-specification-v2.md index ba57148979..3b4631af06 100644 --- a/specs/x402-specification-v2.md +++ b/specs/x402-specification-v2.md @@ -263,8 +263,11 @@ The `VerifyResponse` schema contains the following fields: | `isValid` | `boolean` | Required | Indicates whether the payment authorization is valid | | `invalidReason` | `string` | Optional | Reason for invalidity (omitted if valid) | | `payer` | `string` | Optional | Address of the payer's wallet | +| `extensions` | `object` | Optional | Protocol extensions data | | `extra` | `object` | Optional | Scheme-specific additional data | +Facilitators MAY expose extension outcomes separately from `extensions` as `extensionResponses` (populated from the transport sidechannel; never serialized to buyers). See section 7.2.1. + **6. Payment Schemes (The Logic)** This section describes the payment schemes supported by the x402 protocol. Payment schemes define how payments are formed, validated, and settled on specific payment networks. Schemes are independent of the underlying transport mechanism. @@ -420,6 +423,19 @@ Durably commits payment state for the request — establishing finality from the } ``` +**7.2.1 Extension Responses Sidechannel** + +Facilitators MAY communicate extension-specific processing outcomes on verify and settle responses through a transport-specific sidechannel that is **not** part of the JSON response body and **not** forwarded to buyers. + +On HTTP, the sidechannel is the `EXTENSION-RESPONSES` header: + +| Property | Value | +| -------- | ----- | +| Header name | `EXTENSION-RESPONSES` | +| Header value | Base64-encoded JSON object keyed by extension name | + +Each key holds the extension's outcome object. Extension specs define the payload shape under their key (for example, `bazaar` in `specs/extensions/bazaar.md`). + **7.3 GET /supported** Returns the list of payment schemes, networks, and extensions supported by the facilitator. @@ -506,7 +522,7 @@ List discoverable x402 resources from the Bazaar. { "resource": "https://api.example.com/premium-data", "type": "http", - "x402Version": 1, + "x402Version": 2, "accepts": [ { "scheme": "exact", @@ -521,11 +537,7 @@ List discoverable x402 resources from the Bazaar. } } ], - "lastUpdated": 1703123456, - "metadata": { - "category": "finance", - "provider": "Example Corp" - } + "lastUpdated": "2025-08-09T01:07:04.005Z" } ], "pagination": { @@ -549,7 +561,7 @@ Search semantics and response shape are defined in the Bazaar extension specific | `type` | `string` | Required | Resource type (currently "http" for HTTP endpoints) | | `x402Version` | `number` | Required | Protocol version supported by the resource | | `accepts` | `array` | Required | Array of PaymentRequirements objects specifying payment methods | -| `lastUpdated` | `number` | Required | Unix timestamp of when the resource was last updated | +| `lastUpdated` | `string` | Required | ISO 8601 timestamp of when the resource was last updated | | `extensions` | `object` | Optional | Additional extension payloads associated with this discovered resource | **8.4 Bazaar Concept** diff --git a/typescript/.changeset/add-ethereum-avalanche-default-assets.md b/typescript/.changeset/add-ethereum-avalanche-default-assets.md deleted file mode 100644 index 3fd8e5eda6..0000000000 --- a/typescript/.changeset/add-ethereum-avalanche-default-assets.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/evm": minor ---- - -Add Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) native USDC as the default stablecoin diff --git a/typescript/.changeset/add-sei-default-stablecoin.md b/typescript/.changeset/add-sei-default-stablecoin.md deleted file mode 100644 index 8e750aa2e7..0000000000 --- a/typescript/.changeset/add-sei-default-stablecoin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@x402/evm': minor -'@x402/paywall': patch ---- - -Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin diff --git a/typescript/.changeset/batch-settlement-local-truth.md b/typescript/.changeset/batch-settlement-local-truth.md deleted file mode 100644 index 87bb240f34..0000000000 --- a/typescript/.changeset/batch-settlement-local-truth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/evm": patch ---- - -Stop persisting untrusted batch-settlement `channelState` from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped `chargedAmount` and any client-signed deposit, except when a present extra `chargedCumulativeAmount` does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. diff --git a/typescript/.changeset/bazaar-bare-wildcard-route-template.md b/typescript/.changeset/bazaar-bare-wildcard-route-template.md new file mode 100644 index 0000000000..269ac401ea --- /dev/null +++ b/typescript/.changeset/bazaar-bare-wildcard-route-template.md @@ -0,0 +1,5 @@ +--- +"@x402/extensions": patch +--- + +Bazaar discovery no longer emits a `routeTemplate` for bare wildcard (`*`) route patterns. Previously the auto-generated `:var1` template failed CDP's required `matches_resource` check, so Next.js resources using the default `withX402()` wildcard registration were never indexed. diff --git a/typescript/.changeset/extension-responses-sidechannel.md b/typescript/.changeset/extension-responses-sidechannel.md new file mode 100644 index 0000000000..015d329056 --- /dev/null +++ b/typescript/.changeset/extension-responses-sidechannel.md @@ -0,0 +1,5 @@ +--- +"@x402/core": patch +--- + +Populate `extensionResponses` on verify/settle from the facilitator `EXTENSION-RESPONSES` header as a server-internal sidechannel (aligned with Python). Do not merge into `extensions`; strip the sidechannel from buyer-facing `PAYMENT-RESPONSE` encoding. diff --git a/typescript/.changeset/mcp-withhold-content-on-settle-failure.md b/typescript/.changeset/mcp-withhold-content-on-settle-failure.md deleted file mode 100644 index 2ec5980eaf..0000000000 --- a/typescript/.changeset/mcp-withhold-content-on-settle-failure.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@x402/mcp': minor ---- - -Withhold MCP tool content when after-handler settlement returns `{ success: false }`. diff --git a/typescript/.changeset/next-withx402-route-pattern.md b/typescript/.changeset/next-withx402-route-pattern.md new file mode 100644 index 0000000000..a0b4006fe4 --- /dev/null +++ b/typescript/.changeset/next-withx402-route-pattern.md @@ -0,0 +1,5 @@ +--- +"@x402/next": minor +--- + +`withX402` now accepts a `RoutesConfig`, so the route's path pattern can be given explicitly (e.g. `{ "/api/users/[id]": config }`) instead of always registering a wildcard route. Passing a bare route config remains supported and unchanged. When a pattern-keyed config matches no request path, the handler runs without payment and a warning is logged once per wrapper. diff --git a/typescript/.changeset/resource-server-throw-on-unregistered-scheme.md b/typescript/.changeset/resource-server-throw-on-unregistered-scheme.md new file mode 100644 index 0000000000..801af5b17f --- /dev/null +++ b/typescript/.changeset/resource-server-throw-on-unregistered-scheme.md @@ -0,0 +1,7 @@ +--- +"@x402/core": patch +--- + +`x402ResourceServer.buildPaymentRequirements()` now throws when no scheme server is registered for the requested scheme and network, instead of logging a `console.warn` and resolving with an empty array. It previously produced a 402 response carrying an empty `accepts` list, which is unusable to a client and gives no signal that the server is misconfigured. The new error matches the adjacent guard that already throws when the facilitator does not advertise the scheme and network. + +This is a breaking change. A call that previously resolved with `[]` now rejects, so any resource server that reaches a priced route without a registered scheme server changes from answering 402 to surfacing an error. Callers must register a scheme server for every network they price a route on. diff --git a/typescript/.changeset/svm-drop-node-crypto.md b/typescript/.changeset/svm-drop-node-crypto.md new file mode 100644 index 0000000000..e7870a7969 --- /dev/null +++ b/typescript/.changeset/svm-drop-node-crypto.md @@ -0,0 +1,5 @@ +--- +"@x402/svm": patch +--- + +Replace `node:crypto`/`Buffer` usage in `transactionMessageHash`, `getChannelDistributionHash`, and payment-channel account discovery with `@noble/hashes/sha256` and `@solana/kit`'s base64 codec. These SHA-256 call sites were only ever needed by facilitator-side code, but lived in modules also imported by `exact/client`/`upto/client`, pulling Node's `crypto` module (and `Buffer`) into browser bundles of any consumer that imports the SVM client and forcing a `crypto`/`buffer` polyfill (e.g. `vite-plugin-node-polyfills`). `@noble/hashes` is a pure-JS, dependency-free SHA-256 implementation that produces byte-identical digests and needs no platform crypto API or polyfill on any target (browser, Node, React Native). diff --git a/typescript/.changeset/svm-validate-smart-wallet-limits.md b/typescript/.changeset/svm-validate-smart-wallet-limits.md deleted file mode 100644 index 129d567888..0000000000 --- a/typescript/.changeset/svm-validate-smart-wallet-limits.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/svm": patch ---- - -Reject invalid smart-wallet compute-unit and priority-fee limits when smart-wallet verification is enabled or its exported verification helpers are called, preventing non-finite or fractional configuration values from silently disabling fee protections or causing misleading payment failures. Export `assertSmartWalletLimits` and `SmartWalletLimits` for pre-validation. Dormant limits remain ignored while smart-wallet verification is disabled. diff --git a/typescript/packages/core/CHANGELOG.md b/typescript/packages/core/CHANGELOG.md index d252d0a9ec..c6b83612c7 100644 --- a/typescript/packages/core/CHANGELOG.md +++ b/typescript/packages/core/CHANGELOG.md @@ -1,5 +1,10 @@ # @x402/core Changelog +## 2.24.0 + +### Minor Changes + +- Bumped to align version with dependent packages ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/core/package.json b/typescript/packages/core/package.json index 79ea372b2f..7173561eec 100644 --- a/typescript/packages/core/package.json +++ b/typescript/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@x402/core", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/core/src/facilitator/index.ts b/typescript/packages/core/src/facilitator/index.ts index d1409c99a3..3902c0b6d3 100644 --- a/typescript/packages/core/src/facilitator/index.ts +++ b/typescript/packages/core/src/facilitator/index.ts @@ -1 +1,2 @@ export * from "./x402Facilitator"; +export * from "./pendingSettlementStore"; diff --git a/typescript/packages/core/src/facilitator/pendingSettlementStore.ts b/typescript/packages/core/src/facilitator/pendingSettlementStore.ts new file mode 100644 index 0000000000..1d748d19f0 --- /dev/null +++ b/typescript/packages/core/src/facilitator/pendingSettlementStore.ts @@ -0,0 +1,139 @@ +/** + * TTL applied by the default in-memory {@link PendingSettlementStore} + * implementation. A store implementation backed by a different mechanism + * (e.g. Redis, for a multi-instance facilitator) is free to use its own TTL — + * this constant only governs {@link InMemoryPendingSettlementStore}. + */ +export const PENDING_SETTLEMENT_TTL_MS = 5 * 60 * 1000; + +/** + * Lets a facilitator-side mechanism remember a broadcast-but-not-yet-confirmed + * transaction hash, keyed by a deterministic identifier derived from the + * payment payload (e.g. an EIP-3009/Permit2 signature, or an SVM message + * hash/channel id). When a settle attempt's receipt/confirmation wait fails, + * the mechanism stores the broadcast hash here before returning a + * `settlement_pending` error. On a subsequent settle attempt for the same + * payload (typically the resource server's single automatic retry — see + * `x402ResourceServer.settlePayment`), the mechanism checks this store first + * and, on a hit, reconciles against the already-broadcast transaction instead + * of verifying and broadcasting a second one. + * + * This is an interface — not a concrete type — specifically so a + * multi-instance facilitator (running several replicas with no session + * affinity) can supply a shared, network-backed implementation (e.g. Redis) + * instead of the in-memory default, which only works when a retry happens to + * land back on the same process. Implementations must be safe for concurrent + * use. Mechanism code must depend only on this interface, never on + * {@link InMemoryPendingSettlementStore} directly. + */ +export interface PendingSettlementStore { + /** + * Returns the previously stored transaction hash for `key`, if any. + * Returns `undefined` when there is no entry (including one that has + * expired). + * + * @param key - Deterministic identifier derived from the payment payload + * @returns The stored transaction hash, or `undefined` when absent + */ + get(key: string): Promise; + + /** + * Records that `key`'s payment broadcast `txHash` but has not yet been + * confirmed. A subsequent `set` for the same key overwrites the prior + * value. + * + * @param key - Deterministic identifier derived from the payment payload + * @param txHash - The broadcast transaction hash + */ + set(key: string, txHash: string): Promise; + + /** + * Removes any pending entry for `key`, e.g. once the transaction is + * confirmed (success) or the mechanism determines it terminally failed. + * + * @param key - Deterministic identifier derived from the payment payload + */ + delete(key: string): Promise; +} + +/** + * A single {@link InMemoryPendingSettlementStore} record. + */ +interface PendingSettlementEntry { + txHash: string; + storedAt: number; +} + +/** + * The default {@link PendingSettlementStore} implementation: a per-process + * `Map` with lazy TTL pruning (mirrors the shape of + * `@x402/mechanisms-svm`'s `SettlementCache`). It never performs network + * I/O — `get` additionally prunes expired entries (O(n) in the number of + * currently-stored entries, which stays small since entries only exist while + * a settlement is genuinely pending), so every call adds no meaningful + * latency to the settle hot path. + * + * Node.js is single-threaded, so no lock is required here. Suitable for + * single-instance facilitators; multi-instance deployments should inject a + * shared, network-backed {@link PendingSettlementStore} implementation + * instead (e.g. Redis). + */ +export class InMemoryPendingSettlementStore implements PendingSettlementStore { + private readonly entries = new Map(); + + /** + * Returns the previously stored transaction hash for `key`, if any. + * + * @param key - Deterministic identifier derived from the payment payload + * @returns The stored transaction hash, or `undefined` when absent/expired + */ + async get(key: string): Promise { + this.prune(); + return this.entries.get(key)?.txHash; + } + + /** + * Records that `key`'s payment broadcast `txHash` but has not yet been + * confirmed. + * + * @param key - Deterministic identifier derived from the payment payload + * @param txHash - The broadcast transaction hash + */ + async set(key: string, txHash: string): Promise { + this.entries.set(key, { txHash, storedAt: Date.now() }); + } + + /** + * Removes any pending entry for `key`. + * + * @param key - Deterministic identifier derived from the payment payload + */ + async delete(key: string): Promise { + this.entries.delete(key); + } + + /** + * Returns a snapshot of the underlying map — use only in tests. + * + * @returns A plain object mapping each stored key to its transaction hash + */ + entriesSnapshot(): Record { + const out: Record = {}; + for (const [key, entry] of this.entries) { + out[key] = entry.txHash; + } + return out; + } + + /** + * Removes entries older than {@link PENDING_SETTLEMENT_TTL_MS}. + */ + private prune(): void { + const cutoff = Date.now() - PENDING_SETTLEMENT_TTL_MS; + for (const [key, entry] of this.entries) { + if (entry.storedAt < cutoff) { + this.entries.delete(key); + } + } + } +} diff --git a/typescript/packages/core/src/http/httpFacilitatorClient.ts b/typescript/packages/core/src/http/httpFacilitatorClient.ts index 2d6ecac024..c6a6bd71e7 100644 --- a/typescript/packages/core/src/http/httpFacilitatorClient.ts +++ b/typescript/packages/core/src/http/httpFacilitatorClient.ts @@ -253,22 +253,42 @@ function isAbortOrTimeoutError(error: unknown): boolean { const EXTENSION_RESPONSE_LOG_FIELD_ALLOWLIST = ["status", "rejectedReason", "reason", "code"]; /** - * Reads the `EXTENSION-RESPONSES` header from a facilitator HTTP response and logs - * allowlisted fields. Silently ignores malformed headers. + * Decodes the facilitator `EXTENSION-RESPONSES` header into a plain object. + * Returns undefined when the header is missing or malformed. * * @param response - The HTTP response from the facilitator + * @returns Decoded extension responses, or undefined if missing/malformed */ -function logExtensionResponsesHeader(response: Response): void { +function extractExtensionResponsesHeader(response: Response): Record | undefined { const header = response.headers.get("EXTENSION-RESPONSES"); - if (!header) return; + if (!header) return undefined; try { const decoded = JSON.parse(safeBase64Decode(header)); - if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return; + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + return undefined; + } + return decoded as Record; + } catch { + return undefined; + } +} + +/** + * Reads the `EXTENSION-RESPONSES` header from a facilitator HTTP response and logs + * allowlisted fields. Silently ignores malformed headers. + * + * @param response - The HTTP response from the facilitator + * @param decoded - Optional pre-decoded header object (avoids double-parse) + */ +function logExtensionResponsesHeader(response: Response, decoded?: Record): void { + const payload = decoded ?? extractExtensionResponsesHeader(response); + if (!payload) return; + try { const sanitized: Record> = {}; - for (const [extensionKey, payload] of Object.entries(decoded as Record)) { + for (const [extensionKey, value] of Object.entries(payload)) { const source = - payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) : {}; const filtered: Record = {}; for (const key of EXTENSION_RESPONSE_LOG_FIELD_ALLOWLIST) { @@ -280,8 +300,28 @@ function logExtensionResponsesHeader(response: Response): void { } console.log(`[x402] extension responses: ${JSON.stringify(sanitized)}`); } catch { - // Ignore malformed header + // Ignore malformed payload shapes + } +} + +/** + * Attach decoded EXTENSION-RESPONSES onto `extensionResponses` (server-internal + * sidechannel). Never merges into `extensions`, which remains body-only and may + * be forwarded to buyers via PAYMENT-RESPONSE. + * + * @param result - Facilitator response object to annotate + * @param response - The HTTP response from the facilitator + * @returns The same result, possibly with extensionResponses set + */ +function attachExtensionResponsesFromHeader< + T extends { extensionResponses?: Record }, +>(result: T, response: Response): T { + const headerExtensions = extractExtensionResponsesHeader(response); + logExtensionResponsesHeader(response, headerExtensions); + if (headerExtensions) { + result.extensionResponses = headerExtensions; } + return result; } /** @@ -401,8 +441,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient { } const verifyResult = await parseSuccessResponse(response, verifyResponseSchema, "verify"); - logExtensionResponsesHeader(response); - return verifyResult; + return attachExtensionResponsesFromHeader(verifyResult, response); }); } @@ -460,8 +499,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient { } const settleResult = await parseSuccessResponse(response, settleResponseSchema, "settle"); - logExtensionResponsesHeader(response); - return settleResult; + return attachExtensionResponsesFromHeader(settleResult, response); }); } diff --git a/typescript/packages/core/src/http/index.ts b/typescript/packages/core/src/http/index.ts index fca54d9cc6..5d1b0bb6ab 100644 --- a/typescript/packages/core/src/http/index.ts +++ b/typescript/packages/core/src/http/index.ts @@ -61,7 +61,10 @@ export function decodePaymentRequiredHeader(paymentRequiredHeader: string): Paym * @returns Base64 encoded string representation of the payment response */ export function encodePaymentResponseHeader(paymentResponse: SettleResponse): string { - return safeBase64Encode(JSON.stringify(paymentResponse)); + // Strip server-internal facilitator sidechannel; never forward to buyers. + const buyerFacing = { ...paymentResponse }; + delete buyerFacing.extensionResponses; + return safeBase64Encode(JSON.stringify(buyerFacing)); } /** diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index ea1be69eac..cb16b7326e 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -21,6 +21,7 @@ import type { DeepReadonly } from "../types/readonly"; import { ADDITIVE_ARRAY_INFO_FIELDS, ADDITIVE_ARRAY_MAX_LENGTHS, + SERVER_OWNED_INFO_FIELDS, deepEqual, convertToTokenAmount, findByNetworkAndScheme, @@ -142,6 +143,19 @@ export interface SettleFailureContext extends SettleContext { error: Error; } +/** + * Generic (scheme/network-agnostic) settle error reason meaning a + * transaction broadcast successfully but its receipt/confirmation wait + * failed — non-terminal, and always carries the broadcast transaction hash + * so a caller can reconcile onchain. Duplicated here (rather than imported + * from a mechanism package) so core does not depend on the mechanisms + * packages, mirroring Go's `x402.ErrSettlementPending` + * (`go/errors.go`) and its mechanism-level counterparts + * (`evm.ErrSettlementPending`, `svm.ErrSettlementPending`). Used by + * {@link x402ResourceServer.settlePayment}'s single automatic settle retry. + */ +const SETTLEMENT_PENDING_REASON = "settlement_pending"; + export type VerifiedPaymentCancellationReason = | "handler_threw" | "handler_failed" @@ -755,12 +769,10 @@ export class x402ResourceServer { ); if (!SchemeNetworkServer) { - // Fallback to placeholder implementation if no server registered - // TODO: Remove this fallback once implementations are registered - console.warn( - `No server implementation registered for scheme: ${scheme}, network: ${resourceConfig.network}`, + throw new Error( + `No server implementation registered for ${scheme} on ${resourceConfig.network}. ` + + `Make sure to call register() with a scheme server for this network.`, ); - return requirements; } // Find the matching supported kind from facilitator @@ -1315,7 +1327,11 @@ export class x402ResourceServer { for (const client of this.facilitatorClients) { try { - settleResult = await client.settle(settlePayload, effectiveRequirements); + settleResult = await this.settleWithPendingRetry( + client, + settlePayload, + effectiveRequirements, + ); break; } catch (error) { lastError = error as Error; @@ -1332,7 +1348,41 @@ export class x402ResourceServer { } } else { // Use the specific facilitator that supports this payment - settleResult = await facilitatorClient.settle(settlePayload, effectiveRequirements); + settleResult = await this.settleWithPendingRetry( + facilitatorClient, + settlePayload, + effectiveRequirements, + ); + } + + // A returned (non-thrown) settleResult with success:false — e.g. from a + // remote/HTTP facilitator, or a settlement_pending outcome that + // survived the single automatic retry above — previously looked like a + // success: afterSettle hooks would run and callers would treat the + // response as settled. Route it through onSettleFailure like a thrown + // error so hooks get a chance to recover. + if (!settleResult.success) { + const failureContext: SettleFailureContext = { + ...context, + error: settleResponseToError(settleResult), + }; + + for (const { label, hook } of this.getLabeledHooks( + "onSettleFailure", + extensionKeysInUse, + matchedScheme, + )) { + try { + const result = await hook(failureContext); + if (result && "recovered" in result && result.recovered) { + return result.result; + } + } catch (error) { + this.warnResourceServerHookFailure("onSettleFailure", label, error); + } + } + + return settleResult; } // Execute afterSettle hooks @@ -1410,33 +1460,39 @@ export class x402ResourceServer { } const serverExtensions = paymentRequired.extensions; - if (!serverExtensions || Object.keys(serverExtensions).length === 0) { - return { valid: true }; - } - const clientExtensions = paymentPayload.extensions; if (!clientExtensions || Object.keys(clientExtensions).length === 0) { return { valid: true }; } for (const [key, echoedValue] of Object.entries(clientExtensions)) { - if (!Object.prototype.hasOwnProperty.call(serverExtensions, key)) { - continue; - } - - const advertisedInfo = getExtensionInfo(serverExtensions[key]); + const advertisedInfo = getExtensionInfo(serverExtensions?.[key]); const echoedInfo = getExtensionInfo(echoedValue); - const dynamicFields = this.registeredExtensions.get(key)?.dynamicInfoFields; - const additiveFields = ADDITIVE_ARRAY_INFO_FIELDS[key]; - const maxLengths = ADDITIVE_ARRAY_MAX_LENGTHS[key]; + if (serverExtensions && Object.prototype.hasOwnProperty.call(serverExtensions, key)) { + const dynamicFields = this.registeredExtensions.get(key)?.dynamicInfoFields; + const additiveFields = ADDITIVE_ARRAY_INFO_FIELDS[key]; + const maxLengths = ADDITIVE_ARRAY_MAX_LENGTHS[key]; + if ( + !extensionInfoMatchesAdvertised( + omitFields(advertisedInfo, dynamicFields), + omitFields(echoedInfo, dynamicFields), + additiveFields, + maxLengths, + ) + ) { + return { + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: key, + }; + } + } + + const serverOwnedFields = SERVER_OWNED_INFO_FIELDS[key]; if ( - !extensionInfoMatchesAdvertised( - omitFields(advertisedInfo, dynamicFields), - omitFields(echoedInfo, dynamicFields), - additiveFields, - maxLengths, - ) + serverOwnedFields && + !serverOwnedInfoFieldsMatch(advertisedInfo, echoedInfo, serverOwnedFields) ) { return { valid: false, @@ -1499,6 +1555,47 @@ export class x402ResourceServer { } } + /** + * Calls `facilitatorClient.settle` once, then retries exactly once with the + * identical payload/requirements when the outcome is a non-terminal + * `settlement_pending` failure carrying a broadcast transaction hash. Sits + * above all scheme/network dispatch — the mechanism that actually handles + * the retry (via its own `PendingSettlementStore` check) reconciles against + * the already-broadcast transaction instead of verifying and broadcasting + * a second one. No mutation, backoff, or sleep: the mechanism layer owns + * any bounded waiting. Any other outcome (success, or a different failure + * reason) short-circuits after the first call. Capped at exactly one retry + * regardless of the second outcome, so this can never loop. Mirrors Go's + * `settleWithPendingRetry` (`go/server.go`). + * + * @param facilitatorClient - The facilitator client to call + * @param settlePayload - The (possibly enriched) settle-local payload + * @param effectiveRequirements - The effective payment requirements + * @returns The settle result, either from the first attempt or the single retry + */ + private async settleWithPendingRetry( + facilitatorClient: FacilitatorClient, + settlePayload: PaymentPayload, + effectiveRequirements: PaymentRequirements, + ): Promise { + let result: SettleResponse | undefined; + let retryable: boolean; + try { + result = await facilitatorClient.settle(settlePayload, effectiveRequirements); + retryable = isRetryableSettlementPendingResult(result); + } catch (error) { + if (!isRetryableSettlementPendingError(error)) { + throw error; + } + retryable = true; + } + + if (!retryable) { + return result!; + } + return facilitatorClient.settle(settlePayload, effectiveRequirements); + } + /** * Validates that each registered scheme's configuration is compatible with the * facilitator capabilities advertised for the scheme/network combinations it @@ -1854,6 +1951,50 @@ function getExtensionInfo(value: unknown): unknown { return value; } +/** + * Returns whether client-echoed server-owned fields match the server advertisement. + * When the server did not declare the extension, `advertised` is treated as empty + * so clients cannot invent fields such as builder-code `a`. + * + * @param advertised - Extension info advertised by the server, if any. + * @param echoed - Extension info echoed back by the client. + * @param serverOwnedFields - Field names the client must not invent. + * @returns True when every echoed server-owned field matches the advertisement. + */ +function serverOwnedInfoFieldsMatch( + advertised: unknown, + echoed: unknown, + serverOwnedFields: ReadonlySet, +): boolean { + if (echoed === null || typeof echoed !== "object" || Array.isArray(echoed)) { + return true; + } + + const echoedRecord = echoed as Record; + const advertisedRecord = + advertised !== null && typeof advertised === "object" && !Array.isArray(advertised) + ? (advertised as Record) + : {}; + + for (const field of serverOwnedFields) { + if (!Object.prototype.hasOwnProperty.call(echoedRecord, field)) { + continue; + } + const echoedValue = echoedRecord[field]; + if (echoedValue === undefined) { + continue; + } + if ( + !Object.prototype.hasOwnProperty.call(advertisedRecord, field) || + !deepEqual(advertisedRecord[field], echoedValue) + ) { + return false; + } + } + + return true; +} + /** * Returns a copy of an object without the named dynamic fields. * @@ -1896,6 +2037,55 @@ function extensionInfoMatchesAdvertised( return objectContainsSubset(advertised, echoed, additiveFields, maxLengths); } +/** + * Reports whether a returned (non-thrown) settle outcome is a retryable + * `settlement_pending`: `success: false`, `errorReason === "settlement_pending"`, + * and a non-empty `transaction` hash. Mirrors Go's `isRetryableSettlementPending` + * (`go/server.go`) for the HTTP/remote `FacilitatorClient` path. + * + * @param result - The settle response to inspect + * @returns Whether the result is a retryable settlement_pending outcome + */ +function isRetryableSettlementPendingResult(result: SettleResponse): boolean { + return ( + !result.success && result.errorReason === SETTLEMENT_PENDING_REASON && !!result.transaction + ); +} + +/** + * Reports whether a thrown settle outcome is a retryable `settlement_pending`: + * a {@link SettleError} with `errorReason === "settlement_pending"` and a + * non-empty `transaction` hash. Mirrors Go's `isRetryableSettlementPending` + * (`go/server.go`) for the local/in-process `FacilitatorClient` path, where a + * business-logic settlement_pending failure is thrown rather than returned. + * + * @param error - The thrown value to inspect + * @returns Whether the error is a retryable settlement_pending outcome + */ +function isRetryableSettlementPendingError(error: unknown): boolean { + return ( + error instanceof SettleError && + error.errorReason === SETTLEMENT_PENDING_REASON && + !!error.transaction + ); +} + +/** + * Synthesizes an {@link Error} from a returned `success: false` + * {@link SettleResponse} so it can flow through the same + * {@link SettleFailureContext} / {@link OnSettleFailureHook} path as a thrown + * error. Mirrors Go's `settleResponseToError` (`go/server.go`) and Python's + * `Exception(settle_result.error_reason or "Settlement failed")` fallback + * (`python/x402/server_base.py`). + * + * @param result - The failed settle response + * @returns A {@link SettleError} carrying the response's failure details + */ +function settleResponseToError(result: SettleResponse): SettleError { + const reason = result.errorReason || "Settlement failed"; + return new SettleError(500, { ...result, errorReason: reason }); +} + /** * Returns whether a client-selected requirement satisfies a server-advertised requirement. * diff --git a/typescript/packages/core/src/types/facilitator.ts b/typescript/packages/core/src/types/facilitator.ts index fc3bce222e..056a3fc023 100644 --- a/typescript/packages/core/src/types/facilitator.ts +++ b/typescript/packages/core/src/types/facilitator.ts @@ -13,6 +13,7 @@ export type VerifyResponse = { invalidMessage?: string; payer?: string; extensions?: Record; + extensionResponses?: Record; extra?: Record; }; @@ -32,6 +33,7 @@ export type SettleResponse = { /** Actual amount settled in atomic token units. Present for schemes like `upto` where settlement amount may differ from the authorized maximum. */ amount?: string; extensions?: Record; + extensionResponses?: Record; extra?: Record; }; diff --git a/typescript/packages/core/src/utils/index.ts b/typescript/packages/core/src/utils/index.ts index 2c8c216e99..32b8e7f14b 100644 --- a/typescript/packages/core/src/utils/index.ts +++ b/typescript/packages/core/src/utils/index.ts @@ -296,6 +296,16 @@ export const ADDITIVE_ARRAY_INFO_FIELDS: Record> = { "builder-code": new Set(["s"]), }; +/** + * Extension info fields, keyed by extension key, that only the resource server + * may declare. Clients MUST NOT invent these on echo; core cannot import + * `@x402/extensions`, so the key/field list is duplicated here (same as + * {@link ADDITIVE_ARRAY_INFO_FIELDS}). + */ +export const SERVER_OWNED_INFO_FIELDS: Record> = { + "builder-code": new Set(["a"]), +}; + /** * Caps the combined echoed length of an additive array field (see * {@link ADDITIVE_ARRAY_INFO_FIELDS}) so a hand-crafted payload cannot pad the diff --git a/typescript/packages/core/test/unit/facilitator/pendingSettlementStore.test.ts b/typescript/packages/core/test/unit/facilitator/pendingSettlementStore.test.ts new file mode 100644 index 0000000000..a84ea2243a --- /dev/null +++ b/typescript/packages/core/test/unit/facilitator/pendingSettlementStore.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + InMemoryPendingSettlementStore, + PENDING_SETTLEMENT_TTL_MS, + type PendingSettlementStore, +} from "../../../src/facilitator/pendingSettlementStore"; + +describe("InMemoryPendingSettlementStore", () => { + it("returns undefined for a key that was never set", async () => { + const store = new InMemoryPendingSettlementStore(); + expect(await store.get("missing-key")).toBeUndefined(); + }); + + it("returns the stored transaction hash on a hit", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + expect(await store.get("key1")).toBe("0xabc"); + }); + + it("overwrites a prior value on a second set for the same key", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + await store.set("key1", "0xdef"); + expect(await store.get("key1")).toBe("0xdef"); + }); + + it("removes the entry on delete", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + await store.delete("key1"); + expect(await store.get("key1")).toBeUndefined(); + }); + + it("delete is a no-op for a key that was never set", async () => { + const store = new InMemoryPendingSettlementStore(); + await expect(store.delete("missing-key")).resolves.toBeUndefined(); + }); + + it("keeps entries independent by key", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + await store.set("key2", "0xdef"); + expect(await store.get("key1")).toBe("0xabc"); + expect(await store.get("key2")).toBe("0xdef"); + await store.delete("key1"); + expect(await store.get("key1")).toBeUndefined(); + expect(await store.get("key2")).toBe("0xdef"); + }); + + describe("TTL expiry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("prunes an entry once it is older than the TTL", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + + vi.advanceTimersByTime(PENDING_SETTLEMENT_TTL_MS + 1); + + expect(await store.get("key1")).toBeUndefined(); + }); + + it("keeps an entry that has not yet reached the TTL", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + + vi.advanceTimersByTime(PENDING_SETTLEMENT_TTL_MS - 1); + + expect(await store.get("key1")).toBe("0xabc"); + }); + + it("prunes lazily: an expired entry is only removed when accessed", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + await store.set("key2", "0xdef"); + + vi.advanceTimersByTime(PENDING_SETTLEMENT_TTL_MS + 1); + + // Accessing key1 triggers a prune pass that also drops key2. + expect(await store.get("key1")).toBeUndefined(); + expect(await store.get("key2")).toBeUndefined(); + }); + + it("does not prune an entry refreshed by a later set", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set("key1", "0xabc"); + + vi.advanceTimersByTime(PENDING_SETTLEMENT_TTL_MS - 1000); + await store.set("key1", "0xdef"); // refresh storedAt + + vi.advanceTimersByTime(2000); // total elapsed since first set > TTL, but < TTL since refresh + + expect(await store.get("key1")).toBe("0xdef"); + }); + }); +}); + +/** + * A minimal test double proving that mechanism code depending on + * {@link PendingSettlementStore} only needs the interface, never the + * concrete {@link InMemoryPendingSettlementStore} implementation. + */ +class RecordingPendingSettlementStore implements PendingSettlementStore { + readonly getCalls: string[] = []; + readonly setCalls: Array<{ key: string; txHash: string }> = []; + readonly deleteCalls: string[] = []; + private readonly entries = new Map(); + + async get(key: string): Promise { + this.getCalls.push(key); + return this.entries.get(key); + } + + async set(key: string, txHash: string): Promise { + this.setCalls.push({ key, txHash }); + this.entries.set(key, txHash); + } + + async delete(key: string): Promise { + this.deleteCalls.push(key); + this.entries.delete(key); + } +} + +describe("PendingSettlementStore interface (test double)", () => { + it("a custom implementation satisfies the interface and behaves like the default store", async () => { + const store: PendingSettlementStore = new RecordingPendingSettlementStore(); + + expect(await store.get("k")).toBeUndefined(); + await store.set("k", "0x1"); + expect(await store.get("k")).toBe("0x1"); + await store.delete("k"); + expect(await store.get("k")).toBeUndefined(); + }); + + it("records interactions distinctly from the in-memory implementation", async () => { + const store = new RecordingPendingSettlementStore(); + await store.set("k", "0x1"); + await store.get("k"); + await store.delete("k"); + + expect(store.setCalls).toEqual([{ key: "k", txHash: "0x1" }]); + expect(store.getCalls).toEqual(["k"]); + expect(store.deleteCalls).toEqual(["k"]); + }); +}); diff --git a/typescript/packages/core/test/unit/http/httpFacilitatorClient.test.ts b/typescript/packages/core/test/unit/http/httpFacilitatorClient.test.ts index b3d9b6216e..b16ea24009 100644 --- a/typescript/packages/core/test/unit/http/httpFacilitatorClient.test.ts +++ b/typescript/packages/core/test/unit/http/httpFacilitatorClient.test.ts @@ -8,6 +8,7 @@ import { getFacilitatorResponseError, } from "../../../src/types"; import { PaymentPayload, PaymentRequirements } from "../../../src/types/payments"; +import { safeBase64Encode } from "../../../src/utils"; const paymentRequirements: PaymentRequirements = { scheme: "exact", @@ -171,6 +172,116 @@ describe("HTTPFacilitatorClient", () => { expect(result.payer).toBeUndefined(); }); + describe("EXTENSION-RESPONSES header", () => { + const extensionPayload = { + bazaar: { status: "accepted", catalogId: "cat-1" }, + }; + + it("sets extensionResponses from header on settle without touching extensions", async () => { + const header = safeBase64Encode(JSON.stringify(extensionPayload)); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + transaction: "0xabc", + network: "eip155:8453", + }), + { + status: 200, + headers: { "EXTENSION-RESPONSES": header }, + }, + ), + ), + ); + + const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" }); + const result = await client.settle(paymentPayload, paymentRequirements); + + expect(result.extensionResponses).toEqual(extensionPayload); + expect(result.extensions).toBeUndefined(); + }); + + it("sets extensionResponses from header on verify without touching extensions", async () => { + const header = safeBase64Encode(JSON.stringify(extensionPayload)); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + isValid: true, + }), + { + status: 200, + headers: { "EXTENSION-RESPONSES": header }, + }, + ), + ), + ); + + const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" }); + const result = await client.verify(paymentPayload, paymentRequirements); + + expect(result.extensionResponses).toEqual(extensionPayload); + expect(result.extensions).toBeUndefined(); + }); + + it("keeps body extensions independent from header extensionResponses", async () => { + const bodyExtensions = { bazaar: { status: "from-body" } }; + const header = safeBase64Encode(JSON.stringify(extensionPayload)); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + transaction: "0xabc", + network: "eip155:8453", + extensions: bodyExtensions, + }), + { + status: 200, + headers: { "EXTENSION-RESPONSES": header }, + }, + ), + ), + ); + + const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" }); + const result = await client.settle(paymentPayload, paymentRequirements); + + expect(result.extensions).toEqual(bodyExtensions); + expect(result.extensionResponses).toEqual(extensionPayload); + }); + + it("ignores malformed EXTENSION-RESPONSES without throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + transaction: "0xabc", + network: "eip155:8453", + }), + { + status: 200, + headers: { "EXTENSION-RESPONSES": "not-valid-base64!!!" }, + }, + ), + ), + ); + + const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" }); + const result = await client.settle(paymentPayload, paymentRequirements); + + expect(result.success).toBe(true); + expect(result.extensions).toBeUndefined(); + expect(result.extensionResponses).toBeUndefined(); + }); + }); + describe("URL normalization", () => { it("strips trailing slashes from the configured URL", () => { const client = new HTTPFacilitatorClient({ url: "https://x402.org/facilitator/" }); diff --git a/typescript/packages/core/test/unit/http/x402HTTPClient.parsePaymentResult.test.ts b/typescript/packages/core/test/unit/http/x402HTTPClient.parsePaymentResult.test.ts index ab1104f1fd..62d42c1522 100644 --- a/typescript/packages/core/test/unit/http/x402HTTPClient.parsePaymentResult.test.ts +++ b/typescript/packages/core/test/unit/http/x402HTTPClient.parsePaymentResult.test.ts @@ -121,4 +121,17 @@ describe("x402HTTPClient.parsePaymentResult", () => { expect(result).toEqual({ status: 200, paymentStatus: "none", body, header: undefined }); }); + + it("encodePaymentResponseHeader excludes extensionResponses sidechannel", () => { + const settleResponse = buildSettleResponse({ + success: true, + transaction: "0xabc", + }); + settleResponse.extensionResponses = { bazaar: { status: "processing" } }; + const encoded = encodePaymentResponseHeader(settleResponse); + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + expect(decoded.extensionResponses).toBeUndefined(); + expect(decoded.success).toBe(true); + expect(decoded.transaction).toBe("0xabc"); + }); }); diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.settlementPending.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.settlementPending.test.ts new file mode 100644 index 0000000000..e7f026ae60 --- /dev/null +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.settlementPending.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi } from "vitest"; +import { x402ResourceServer } from "../../../src/server/x402ResourceServer"; +import { + MockFacilitatorClient, + buildPaymentPayload, + buildPaymentRequirements, + buildSettleResponse, + buildSupportedResponse, +} from "../../mocks"; +import { SettleError } from "../../../src/types/facilitator"; + +/** + * Tests for the single automatic settle retry on a `settlement_pending` + * outcome (mirrors Go's `settleWithPendingRetry`/`isRetryableSettlementPending`), + * and for the success:false → onSettleFailure routing fix. + */ +describe("x402ResourceServer settlePayment - settlement_pending retry", () => { + it("retries exactly once on a returned settlement_pending failure and returns the retried success", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "0xpendingtx", + }), + ) + .mockResolvedValueOnce(buildSettleResponse({ success: true, transaction: "0xpendingtx" })); + + const server = new x402ResourceServer(mockClient); + const payload = buildPaymentPayload(); + const requirements = buildPaymentRequirements(); + + const result = await server.settlePayment(payload, requirements); + + expect(settleSpy).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + expect(result.transaction).toBe("0xpendingtx"); + }); + + it("retries exactly once on a thrown settlement_pending SettleError and returns the retried success", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const pendingError = new SettleError(402, { + success: false, + errorReason: "settlement_pending", + transaction: "0xpendingtx", + network: buildPaymentRequirements().network, + }); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockRejectedValueOnce(pendingError) + .mockResolvedValueOnce(buildSettleResponse({ success: true, transaction: "0xpendingtx" })); + + const server = new x402ResourceServer(mockClient); + const payload = buildPaymentPayload(); + const requirements = buildPaymentRequirements(); + + const result = await server.settlePayment(payload, requirements); + + expect(settleSpy).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + }); + + it("does not retry on a non-pending failure reason", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "invalid_signature", + transaction: "", + }), + ); + + const server = new x402ResourceServer(mockClient); + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(settleSpy).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_signature"); + }); + + it("does not retry on a pending failure with an empty transaction hash", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "", + }), + ); + + const server = new x402ResourceServer(mockClient); + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(settleSpy).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + }); + + it("does not retry at all on immediate success", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockResolvedValueOnce(buildSettleResponse({ success: true })); + + const server = new x402ResourceServer(mockClient); + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(settleSpy).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + }); + + it("caps retries at exactly one even when the retry is also settlement_pending", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const pendingResponse = buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "0xstillpending", + }); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockResolvedValueOnce(pendingResponse) + .mockResolvedValueOnce(pendingResponse); + + const server = new x402ResourceServer(mockClient); + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + // Exactly one retry: no third attempt regardless of the second outcome. + expect(settleSpy).toHaveBeenCalledTimes(2); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("settlement_pending"); + expect(result.transaction).toBe("0xstillpending"); + }); + + it("retries with the exact same payload/requirements on both attempts (no mutation)", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "0xpendingtx", + }), + ) + .mockResolvedValueOnce(buildSettleResponse({ success: true })); + + const server = new x402ResourceServer(mockClient); + const payload = buildPaymentPayload(); + const requirements = buildPaymentRequirements(); + + await server.settlePayment(payload, requirements); + + expect(settleSpy.mock.calls).toHaveLength(2); + expect(settleSpy.mock.calls[0][0]).toEqual(settleSpy.mock.calls[1][0]); + expect(settleSpy.mock.calls[0][1]).toEqual(settleSpy.mock.calls[1][1]); + }); + + it("also retries via the fallback (no-specific-facilitator) settle path", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const settleSpy = vi + .spyOn(mockClient, "settle") + .mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "0xpendingtx", + }), + ) + .mockResolvedValueOnce(buildSettleResponse({ success: true })); + + // No initialize()/register() call: settlePayment falls through the + // "no specific facilitator found" loop, which iterates all registered + // clients directly rather than using the version/network/scheme map. + const server = new x402ResourceServer(mockClient); + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(settleSpy).toHaveBeenCalledTimes(2); + expect(result.success).toBe(true); + }); +}); + +describe("x402ResourceServer settlePayment - success:false routes through onSettleFailure", () => { + it("routes a returned success:false result through onSettleFailure and returns its recovered result", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "invalid_signature", + transaction: "", + }), + ); + + const server = new x402ResourceServer(mockClient); + const recoveredResponse = buildSettleResponse({ success: true, transaction: "0xrecovered" }); + const hook = vi.fn().mockResolvedValue({ recovered: true, result: recoveredResponse }); + server.onSettleFailure(hook); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(hook).toHaveBeenCalledTimes(1); + const failureContext = hook.mock.calls[0][0]; + expect(failureContext.error).toBeInstanceOf(Error); + expect(failureContext.error.message).toContain("invalid_signature"); + expect(result).toEqual(recoveredResponse); + }); + + it("returns the success:false response as-is (does not throw) when no hook recovers it", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ + success: false, + errorReason: "invalid_signature", + transaction: "", + }), + ); + + const server = new x402ResourceServer(mockClient); + const hook = vi.fn().mockResolvedValue(undefined); + server.onSettleFailure(hook); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(hook).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_signature"); + }); + + it("falls back to a generic 'Settlement failed' reason when errorReason is empty", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ success: false, errorReason: undefined, transaction: "" }), + ); + + const server = new x402ResourceServer(mockClient); + const hook = vi.fn().mockResolvedValue(undefined); + server.onSettleFailure(hook); + + await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + const failureContext = hook.mock.calls[0][0]; + expect(failureContext.error.message).toContain("Settlement failed"); + }); + + it("does not run afterSettle hooks when the final result is success:false", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + vi.spyOn(mockClient, "settle").mockResolvedValueOnce( + buildSettleResponse({ success: false, errorReason: "invalid_signature", transaction: "" }), + ); + + const server = new x402ResourceServer(mockClient); + const afterSettle = vi.fn().mockResolvedValue(undefined); + server.onAfterSettle(afterSettle); + + await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(afterSettle).not.toHaveBeenCalled(); + }); + + it("still runs afterSettle (not onSettleFailure) when the result is success:true", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + vi.spyOn(mockClient, "settle").mockResolvedValueOnce(buildSettleResponse({ success: true })); + + const server = new x402ResourceServer(mockClient); + const afterSettle = vi.fn().mockResolvedValue(undefined); + const onSettleFailure = vi.fn().mockResolvedValue(undefined); + server.onAfterSettle(afterSettle); + server.onSettleFailure(onSettleFailure); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(result.success).toBe(true); + expect(afterSettle).toHaveBeenCalledTimes(1); + expect(onSettleFailure).not.toHaveBeenCalled(); + }); + + it("routes a settlement_pending failure that survives the retry through onSettleFailure too", async () => { + const mockClient = new MockFacilitatorClient(buildSupportedResponse()); + const pendingResponse = buildSettleResponse({ + success: false, + errorReason: "settlement_pending", + transaction: "0xstillpending", + }); + vi.spyOn(mockClient, "settle") + .mockResolvedValueOnce(pendingResponse) + .mockResolvedValueOnce(pendingResponse); + + const server = new x402ResourceServer(mockClient); + const hook = vi.fn().mockResolvedValue(undefined); + server.onSettleFailure(hook); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(hook).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + expect(result.transaction).toBe("0xstillpending"); + }); +}); diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index 665bfb5824..b56f192e47 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -505,18 +505,18 @@ describe("x402ResourceServer", () => { expect(requirements[0].maxTimeoutSeconds).toBe(600); }); - it("should return empty array if no scheme registered for network", async () => { + it("should throw if no scheme registered for network", async () => { const server = new x402ResourceServer(); - const requirements = await server.buildPaymentRequirements({ - scheme: "test-scheme", - payTo: "recipient", - price: 1.0, - network: "test:network" as Network, - }); - - // Current implementation returns empty array and logs warning - expect(requirements).toEqual([]); + await expect( + async () => + await server.buildPaymentRequirements({ + scheme: "test-scheme", + payTo: "recipient", + price: 1.0, + network: "test:network" as Network, + }), + ).rejects.toThrow("No server implementation registered for test-scheme on test:network"); }); it("should throw if facilitator doesn't support scheme/network", async () => { @@ -1996,6 +1996,70 @@ describe("x402ResourceServer", () => { expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); }); + it("fails when client forges builder-code app code without server declaration", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: undefined }); + const payload = buildPaymentPayload({ + extensions: { + "builder-code": { info: { a: "forged_app" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "builder-code", + }); + }); + + it("fails when client forges builder-code app code while server declares other extensions", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + "builder-code": { info: { a: "forged_app" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "builder-code", + }); + }); + + it("passes when client sends only builder-code service codes without server declaration", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: undefined }); + const payload = buildPaymentPayload({ + extensions: { + "builder-code": { info: { s: ["bc_client"] } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("fails when client forges builder-code app code that mismatches server declaration", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ + extensions: { + "builder-code": { info: { a: "bc_app" } }, + }, + }); + const payload = buildPaymentPayload({ + extensions: { + "builder-code": { info: { a: "forged_app" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "builder-code", + }); + }); + it("passes when client omits extensions", () => { const server = new x402ResourceServer(); const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); @@ -2095,6 +2159,19 @@ describe("x402ResourceServer", () => { expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); }); + it("passes for v1 payloads with forged builder-code app code", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: undefined }); + const payload = buildPaymentPayload({ + x402Version: 1, + extensions: { + "builder-code": { info: { a: "forged_app" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + it("passes when echoed array is a superset of the advertised array", () => { const server = new x402ResourceServer(); const paymentRequired = buildPaymentRequired({ diff --git a/typescript/packages/extensions/CHANGELOG.md b/typescript/packages/extensions/CHANGELOG.md index 2197926b2b..2527d72d12 100644 --- a/typescript/packages/extensions/CHANGELOG.md +++ b/typescript/packages/extensions/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/extensions Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/extensions/package.json b/typescript/packages/extensions/package.json index 44c40e74d4..394fba5dde 100644 --- a/typescript/packages/extensions/package.json +++ b/typescript/packages/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@x402/extensions", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/extensions/src/bazaar/server.ts b/typescript/packages/extensions/src/bazaar/server.ts index b7a42a7f0e..3ac834ac65 100644 --- a/typescript/packages/extensions/src/bazaar/server.ts +++ b/typescript/packages/extensions/src/bazaar/server.ts @@ -46,6 +46,19 @@ function normalizeWildcardPattern(pattern: string): string { .join("/"); } +/** + * Returns true when a route pattern contains only wildcard segments (e.g. `"*"`), + * with no literal path segments. Bare wildcards must not produce a routeTemplate + * for discovery catalog indexing. + * + * @param pattern - Route pattern from route matching (path only, no HTTP method) + * @returns True if every segment is a wildcard + */ +function isWildcardOnlyPattern(pattern: string): boolean { + const segments = pattern.split("/").filter(segment => segment.length > 0); + return segments.length > 0 && segments.every(segment => segment === "*"); +} + /** * Converts a parameterized route pattern into a :param template and extracts concrete * param values from the URL path in a single call. @@ -200,6 +213,9 @@ export const bazaarResourceServerExtension: ResourceServerExtension = { // pathParams carries runtime values (distinct from pathParamsSchema in the declaration). // Wildcard * segments are auto-converted to :var1, :var2, etc. for catalog normalization. const rawRoutePattern = (transportContext as HTTPRequestContext).routePattern; + if (rawRoutePattern && isWildcardOnlyPattern(rawRoutePattern)) { + return enrichedResult; + } const routePattern = rawRoutePattern ? normalizeWildcardPattern(rawRoutePattern) : undefined; const dynamicRoute = routePattern ? extractDynamicRouteInfo(routePattern, transportContext.adapter.getPath()) diff --git a/typescript/packages/extensions/src/builder-code/facilitator.ts b/typescript/packages/extensions/src/builder-code/facilitator.ts index 6f88053c35..a32dcb53c8 100644 --- a/typescript/packages/extensions/src/builder-code/facilitator.ts +++ b/typescript/packages/extensions/src/builder-code/facilitator.ts @@ -98,7 +98,8 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { /** * Builds the ERC-8021 Schema 2 calldata suffix for a settlement transaction. * - * - `a` and `s` are read from the client's payment payload extensions. + * - On v2 payloads, `a` is read from the client's payment payload extensions. + * - On v1 payloads, client `a` is omitted (no resource-server echo gate); `s` is still read. * - `w` is the facilitator's own code when configured. * - The facilitator's own `s` entry (`config.serviceCode`) is appended after the * echoed client/server codes, within its own {@link MAX_FACILITATOR_SERVICE_CODES} @@ -109,9 +110,11 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { */ buildDataSuffix(ctx: DataSuffixContext): Hex | undefined { const clientExt = extractClientExtension(ctx.paymentPayload.extensions); - + // v1 payloads omit `a`: the resource-server echo gate does not run on v1. const a = - typeof clientExt?.a === "string" && BUILDER_CODE_PATTERN.test(clientExt.a) + ctx.paymentPayload.x402Version === 2 && + typeof clientExt?.a === "string" && + BUILDER_CODE_PATTERN.test(clientExt.a) ? clientExt.a : undefined; const echoedServiceCodes = resolveServiceCodes(clientExt?.s); diff --git a/typescript/packages/extensions/test/bazaar.test.ts b/typescript/packages/extensions/test/bazaar.test.ts index d7979da2a8..3eac70cfe4 100644 --- a/typescript/packages/extensions/test/bazaar.test.ts +++ b/typescript/packages/extensions/test/bazaar.test.ts @@ -1972,6 +1972,28 @@ describe("Bazaar Discovery Extension", () => { expect(input.pathParams).toEqual({ var1: "san-francisco" }); }); + it("should not emit routeTemplate for a bare wildcard * pattern", () => { + const declared = declareDiscoveryExtension({ + input: {}, + inputSchema: { properties: {} }, + }); + const extension = declared.bazaar; + + const httpContext: HTTPRequestContext = { + method: "GET", + path: "/api/rotation", + routePattern: "*", + adapter: createMockAdapterWithPath("/api/rotation"), + }; + + const enriched = bazaarResourceServerExtension.enrichDeclaration!( + extension, + httpContext, + ) as Record; + + expect(enriched.routeTemplate).toBeUndefined(); + }); + it("should auto-convert multiple wildcards to :var1, :var2, etc.", () => { const declared = declareDiscoveryExtension({ input: {}, diff --git a/typescript/packages/extensions/test/builder-code.test.ts b/typescript/packages/extensions/test/builder-code.test.ts index 7f2c8bcb5c..64c0b1c75d 100644 --- a/typescript/packages/extensions/test/builder-code.test.ts +++ b/typescript/packages/extensions/test/builder-code.test.ts @@ -398,6 +398,47 @@ describe("Builder Code Extension", () => { expect(parsed).toEqual({ w: WALLET, a: APP }); }); + + it("drops client app code on v1 payloads but still encodes service codes", () => { + const ext = new BuilderCodeFacilitatorExtension({ + builderCode: WALLET, + serviceCode: "bc_fac", + }); + const suffix = ext.buildDataSuffix({ + ...suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { info: { a: APP, s: SERVICE }, schema: {} }, + }, + }), + paymentPayload: { + ...basePayload(), + x402Version: 1, + extensions: { + [BUILDER_CODE]: { info: { a: APP, s: SERVICE }, schema: {} }, + }, + }, + }); + if (!suffix) { + throw new Error("Expected builder-code suffix"); + } + + const parsed = parseBuilderCodeSuffixFromCalldata( + `0xdeadbeef${suffix.slice(2)}` as `0x${string}`, + ); + expect(parsed).toEqual({ w: WALLET, s: [SERVICE, "bc_fac"] }); + }); + + it("encodes service codes on v2 payloads when app code is absent", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { info: { s: SERVICE }, schema: {} }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET, s: [SERVICE] }); + }); }); describe("suffix encode and parse", () => { diff --git a/typescript/packages/extensions/test/integrations/builder-code.test.ts b/typescript/packages/extensions/test/integrations/builder-code.test.ts index 3731c76712..6730fa8f88 100644 --- a/typescript/packages/extensions/test/integrations/builder-code.test.ts +++ b/typescript/packages/extensions/test/integrations/builder-code.test.ts @@ -270,4 +270,28 @@ describe("Builder Code Integration Tests", () => { const parsed = parseBuilderCodeSuffixFromCalldata(`0x${"00".repeat(4)}${suffix.slice(2)}`); expect(parsed).toEqual({ w: WALLET, s: [SERVICE] }); }); + + it("rejects forged builder-code app code when server did not declare builder-code", async () => { + const accepts = [buildCashPaymentRequirements("merchant@example.com", "USD", "1")]; + const resource = { + url: "https://example.com/api/weather", + description: "Weather API", + mimeType: "application/json", + }; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + paymentPayload.extensions = { + ...paymentPayload.extensions, + [BUILDER_CODE]: { + info: { a: "forged_app", s: [SERVICE] }, + }, + }; + + expect(server.validateExtensions(paymentRequired, paymentPayload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: BUILDER_CODE, + }); + }); }); diff --git a/typescript/packages/http/axios/CHANGELOG.md b/typescript/packages/http/axios/CHANGELOG.md index e133cab6fc..139dbb10d9 100644 --- a/typescript/packages/http/axios/CHANGELOG.md +++ b/typescript/packages/http/axios/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/axios Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/axios/package.json b/typescript/packages/http/axios/package.json index a431cb6913..0da346f722 100644 --- a/typescript/packages/http/axios/package.json +++ b/typescript/packages/http/axios/package.json @@ -1,6 +1,6 @@ { "name": "@x402/axios", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/express/CHANGELOG.md b/typescript/packages/http/express/CHANGELOG.md index abd9994ef8..e94956014d 100644 --- a/typescript/packages/http/express/CHANGELOG.md +++ b/typescript/packages/http/express/CHANGELOG.md @@ -1,5 +1,14 @@ # @x402/express Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies [6557149](https://github.com/x402-foundation/x402/commit/6557149) + - @x402/paywall@2.24.0 + - @x402/core@2.24.0 + - @x402/extensions@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/express/package.json b/typescript/packages/http/express/package.json index f2d8d51c75..04a4697b8a 100644 --- a/typescript/packages/http/express/package.json +++ b/typescript/packages/http/express/package.json @@ -1,6 +1,6 @@ { "name": "@x402/express", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/express/src/encodedPathBypass.test.ts b/typescript/packages/http/express/src/encodedPathBypass.test.ts index 05afd4fdbf..6c770335a7 100644 --- a/typescript/packages/http/express/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/express/src/encodedPathBypass.test.ts @@ -6,6 +6,41 @@ import type { AddressInfo } from "node:net"; import { x402ResourceServer } from "@x402/core/server"; import { paymentMiddleware } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + +/** + * Builds an initialized resource server with the `exact` scheme registered and + * a stub facilitator advertising it, so a matched route answers 402 instead of + * failing to build payment requirements. + * + * @returns A resource server that serves a real 402 for a matched route. + */ +async function buildTestResourceServer(): Promise { + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + return resourceServer; +} + /** * Issue a single HTTP GET to the given port + raw path and return the * response status. The path is sent verbatim — Node does not re-encode @@ -32,7 +67,7 @@ describe("express end-to-end: encoded path separator", () => { beforeAll(async () => { const app = express(); - const resourceServer = new x402ResourceServer(); + const resourceServer = await buildTestResourceServer(); app.use( paymentMiddleware( { @@ -41,7 +76,7 @@ describe("express end-to-end: encoded path separator", () => { scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, @@ -98,7 +133,7 @@ describe("express end-to-end: trailing wildcard route prefix", () => { beforeAll(async () => { const app = express(); - const resourceServer = new x402ResourceServer(); + const resourceServer = await buildTestResourceServer(); app.use( paymentMiddleware( { diff --git a/typescript/packages/http/express/src/fallbackPaywallXss.test.ts b/typescript/packages/http/express/src/fallbackPaywallXss.test.ts index 443f786d6c..72cc8dff67 100644 --- a/typescript/packages/http/express/src/fallbackPaywallXss.test.ts +++ b/typescript/packages/http/express/src/fallbackPaywallXss.test.ts @@ -6,6 +6,41 @@ import type { AddressInfo } from "node:net"; import { x402ResourceServer } from "@x402/core/server"; import { paymentMiddleware } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + +/** + * Builds an initialized resource server with the `exact` scheme registered and + * a stub facilitator advertising it, so a matched route answers 402 instead of + * failing to build payment requirements. + * + * @returns A resource server that serves a real 402 for a matched route. + */ +async function buildTestResourceServer(): Promise { + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + return resourceServer; +} + /** * Issue an HTTP GET to the local server, simulating a real browser * (Accept: text/html + Mozilla User-Agent so the middleware serves the @@ -45,7 +80,7 @@ describe("express end-to-end: fallback paywall HTML does not reflect attacker in beforeAll(async () => { const app = express(); - const resourceServer = new x402ResourceServer(); + const resourceServer = await buildTestResourceServer(); app.use( paymentMiddleware( { @@ -54,7 +89,7 @@ describe("express end-to-end: fallback paywall HTML does not reflect attacker in scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, diff --git a/typescript/packages/http/express/src/lineTerminatorBypass.test.ts b/typescript/packages/http/express/src/lineTerminatorBypass.test.ts index e145e58eeb..794575f869 100644 --- a/typescript/packages/http/express/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/express/src/lineTerminatorBypass.test.ts @@ -6,6 +6,41 @@ import type { AddressInfo } from "node:net"; import { x402ResourceServer } from "@x402/core/server"; import { paymentMiddleware } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + +/** + * Builds an initialized resource server with the `exact` scheme registered and + * a stub facilitator advertising it, so a matched route answers 402 instead of + * failing to build payment requirements. + * + * @returns A resource server that serves a real 402 for a matched route. + */ +async function buildTestResourceServer(): Promise { + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + return resourceServer; +} + /** * Issue a single HTTP GET to the given port + raw path and return the * response status. The path is sent verbatim — Node does not re-encode @@ -41,7 +76,7 @@ describe("express end-to-end: percent-encoded line terminator under wildcard rou beforeAll(async () => { const app = express(); - const resourceServer = new x402ResourceServer(); + const resourceServer = await buildTestResourceServer(); app.use( paymentMiddleware( { @@ -50,7 +85,7 @@ describe("express end-to-end: percent-encoded line terminator under wildcard rou scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, diff --git a/typescript/packages/http/fastify/CHANGELOG.md b/typescript/packages/http/fastify/CHANGELOG.md index 42fe45e4d1..75f882bf1a 100644 --- a/typescript/packages/http/fastify/CHANGELOG.md +++ b/typescript/packages/http/fastify/CHANGELOG.md @@ -1,5 +1,14 @@ # @x402/fastify +## 2.24.0 + +### Minor Changes + +- Updated dependencies [6557149](https://github.com/x402-foundation/x402/commit/6557149) + - @x402/paywall@2.24.0 + - @x402/core@2.24.0 + - @x402/extensions@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/fastify/package.json b/typescript/packages/http/fastify/package.json index 4c30e25212..63c9b16c8d 100644 --- a/typescript/packages/http/fastify/package.json +++ b/typescript/packages/http/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fastify", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts index b0a670f924..6d5892a49e 100644 --- a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts @@ -5,6 +5,41 @@ import type { AddressInfo } from "node:net"; import { x402ResourceServer } from "@x402/core/server"; import { paymentMiddleware } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + +/** + * Builds an initialized resource server with the `exact` scheme registered and + * a stub facilitator advertising it, so a matched route answers 402 instead of + * failing to build payment requirements. + * + * @returns A resource server that serves a real 402 for a matched route. + */ +async function buildTestResourceServer(): Promise { + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + return resourceServer; +} + /** * Issue a single HTTP GET to the given port + raw path and return the * response status. The path is sent verbatim — Node does not re-encode @@ -41,7 +76,7 @@ describe("fastify end-to-end: percent-encoded line terminator under wildcard rou beforeAll(async () => { app = Fastify(); - const resourceServer = new x402ResourceServer(); + const resourceServer = await buildTestResourceServer(); paymentMiddleware( app, { @@ -50,7 +85,7 @@ describe("fastify end-to-end: percent-encoded line terminator under wildcard rou scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, diff --git a/typescript/packages/http/fetch/CHANGELOG.md b/typescript/packages/http/fetch/CHANGELOG.md index bbd55bc212..4792d58921 100644 --- a/typescript/packages/http/fetch/CHANGELOG.md +++ b/typescript/packages/http/fetch/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/fetch Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/fetch/package.json b/typescript/packages/http/fetch/package.json index f5c42bb6fa..ad3388bb41 100644 --- a/typescript/packages/http/fetch/package.json +++ b/typescript/packages/http/fetch/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fetch", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/hono/CHANGELOG.md b/typescript/packages/http/hono/CHANGELOG.md index b0e957d794..95ddc21f6f 100644 --- a/typescript/packages/http/hono/CHANGELOG.md +++ b/typescript/packages/http/hono/CHANGELOG.md @@ -1,5 +1,14 @@ # @x402/hono Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies [6557149](https://github.com/x402-foundation/x402/commit/6557149) + - @x402/paywall@2.24.0 + - @x402/core@2.24.0 + - @x402/extensions@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/hono/package.json b/typescript/packages/http/hono/package.json index 784f47a567..4739c67cd3 100644 --- a/typescript/packages/http/hono/package.json +++ b/typescript/packages/http/hono/package.json @@ -1,6 +1,6 @@ { "name": "@x402/hono", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/hono/src/encodedPathBypass.test.ts b/typescript/packages/http/hono/src/encodedPathBypass.test.ts index 18a7e9d85a..132505a9de 100644 --- a/typescript/packages/http/hono/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/hono/src/encodedPathBypass.test.ts @@ -13,9 +13,29 @@ import { paymentMiddleware } from "./index"; * @returns A Hono app with a paid `:id` route and a catch-all, so a skipped * paywall is observable as the paid body rather than a framework 404. */ -function buildApp() { +async function buildApp() { const app = new Hono(); - const resourceServer = new x402ResourceServer(); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:84532" }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: "eip155:84532" }), + }); + resourceServer.register("eip155:84532", { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); app.use( "*", paymentMiddleware( @@ -43,7 +63,7 @@ function buildApp() { describe("hono end-to-end: encoded path separator in a :param segment", () => { it("returns 402 for a baseline single-segment :id", async () => { - const res = await buildApp().request("/api/report/baseline"); + const res = await (await buildApp()).request("/api/report/baseline"); expect(res.status).toBe(402); }); @@ -53,7 +73,7 @@ describe("hono end-to-end: encoded path separator in a :param segment", () => { ["encoded backslash %5C", "/api/report/a%5Cb"], ["encoded backslash %5c (lowercase)", "/api/report/a%5cb"], ])("returns 402, not the paid body, for %s", async (_, path) => { - const res = await buildApp().request(path); + const res = await (await buildApp()).request(path); expect(res.status).toBe(402); expect(await res.text()).not.toContain("PAID_CONTENT"); diff --git a/typescript/packages/http/hono/src/lineTerminatorBypass.test.ts b/typescript/packages/http/hono/src/lineTerminatorBypass.test.ts index b2455c5d0b..fb6dec9d29 100644 --- a/typescript/packages/http/hono/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/hono/src/lineTerminatorBypass.test.ts @@ -3,6 +3,9 @@ import { Hono } from "hono"; import { x402HTTPResourceServer, x402ResourceServer } from "@x402/core/server"; import { paymentMiddleware } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + /** * Checks CAT finding f2e83cec-d5d5-4076-bd4e-55e060d216b1 against the real * Hono request pipeline (not a hand-built mock Context). @@ -63,11 +66,36 @@ describe("hono end-to-end: percent-encoded line terminator under wildcard route" * wildcard route, plus a catch-all so an unprotected path resolves to * 200 instead of a framework 404. * + * The resource server has the `exact` scheme registered and a stub + * facilitator advertising it, so a matched route answers 402 rather than + * failing to build payment requirements. + * * @returns The configured Hono app. */ - function buildApp() { + async function buildApp() { const app = new Hono(); - const resourceServer = new x402ResourceServer(); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + app.use( "*", paymentMiddleware( @@ -77,7 +105,7 @@ describe("hono end-to-end: percent-encoded line terminator under wildcard route" scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, @@ -102,7 +130,7 @@ describe("hono end-to-end: percent-encoded line terminator under wildcard route" }); it("returns 402 for a baseline wildcard match, and requiresPayment is called", async () => { - const res = await buildApp().request("/api/premium/report"); + const res = await (await buildApp()).request("/api/premium/report"); expect(res.status).toBe(402); expect(requiresPaymentSpy).toHaveBeenCalledTimes(1); }); @@ -115,7 +143,7 @@ describe("hono end-to-end: percent-encoded line terminator under wildcard route" ])( "returns 404 for an encoded %s and never calls requiresPayment (no bypass reaches the paid handler)", async (_, path) => { - const res = await buildApp().request(path); + const res = await (await buildApp()).request(path); expect(res.status).toBe(404); expect(requiresPaymentSpy).not.toHaveBeenCalled(); @@ -123,7 +151,7 @@ describe("hono end-to-end: percent-encoded line terminator under wildcard route" ); it("returns 200 (middleware skipped) for an unrelated path", async () => { - const res = await buildApp().request("/health"); + const res = await (await buildApp()).request("/health"); expect(res.status).toBe(200); expect(requiresPaymentSpy).toHaveBeenCalledTimes(1); }); diff --git a/typescript/packages/http/next/CHANGELOG.md b/typescript/packages/http/next/CHANGELOG.md index ad87be24e1..62c4b6d40c 100644 --- a/typescript/packages/http/next/CHANGELOG.md +++ b/typescript/packages/http/next/CHANGELOG.md @@ -1,5 +1,14 @@ # @x402/next Changelog +## 2.24.0 + +### Minor Changes + +- Updated dependencies [6557149](https://github.com/x402-foundation/x402/commit/6557149) + - @x402/paywall@2.24.0 + - @x402/core@2.24.0 + - @x402/extensions@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/next/README.md b/typescript/packages/http/next/README.md index f04a21e256..f96d7a6927 100644 --- a/typescript/packages/http/next/README.md +++ b/typescript/packages/http/next/README.md @@ -48,6 +48,8 @@ export const config = { API routes are protected using the `withX402` route wrapper. This is the recommended approach to protect API routes as it guarantees payment settlement only AFTER successful API responses (status < 400). API routes can also be protected by `paymentProxy`, however this will charge clients for failed API responses: +**Static routes** — pass a bare route config (second argument). Payment matches any request to the handler; no path key required: + ```typescript // app/api/your-endpoint/route.ts import { NextRequest, NextResponse } from "next/server"; @@ -72,6 +74,30 @@ export const GET = withX402( ); ``` +**Dynamic routes** (`/api/users/[id]`, catch-all `[...slug]`, etc.) — key the config by the route's path pattern so bazaar discovery gets the correct `routeTemplate` and path params: + +```typescript +export const GET = withX402( + handler, + { + "/api/users/[id]": { + accepts: { + scheme: "exact", + price: "$0.01", + network: "eip155:84532", + payTo: "0xYourAddress", + }, + description: "Access to user API", + }, + }, + server, +); +``` + +For static routes that use bazaar discovery (`declareDiscoveryExtension`), you may also key by path (e.g. `{ "/api/your-endpoint": config }`) — optional, but makes the catalog URL explicit. + +The pattern must match the request path exactly as served (including any `basePath`, no trailing slash). If a keyed pattern does not match — a typo, a missing dynamic segment — the handler runs **without** payment protection; `withX402` logs a warning once when this happens. + ## Configuration ### paymentProxy @@ -103,7 +129,7 @@ The `withX402` function wraps API route handlers. This is the recommended approa ```typescript withX402( routeHandler: (request: NextRequest) => Promise, - routeConfig: RouteConfig, + routes: RoutesConfig, server: x402ResourceServer, paywallConfig?: PaywallConfig, paywall?: PaywallProvider, @@ -114,7 +140,7 @@ withX402( #### Parameters 1. **`routeHandler`** (required): Your API route handler function -2. **`routeConfig`** (required): Payment configuration for this specific route +2. **`routes`** (required): Payment configuration — a **bare `RouteConfig`** for static routes (matches any path to the handler), or a **map keyed by path pattern** for dynamic routes (e.g. `{ "/api/users/[id]": config }`). See [Protecting API Routes](#protecting-api-routes) above. 3. **`server`** (required): Pre-configured x402ResourceServer instance 4. **`paywallConfig`** (optional): Configuration for the built-in paywall UI 5. **`paywall`** (optional): Custom paywall provider diff --git a/typescript/packages/http/next/package.json b/typescript/packages/http/next/package.json index 48fc944241..052241ad69 100644 --- a/typescript/packages/http/next/package.json +++ b/typescript/packages/http/next/package.json @@ -1,6 +1,6 @@ { "name": "@x402/next", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/next/src/index.test.ts b/typescript/packages/http/next/src/index.test.ts index 5014829b85..26f9709f47 100644 --- a/typescript/packages/http/next/src/index.test.ts +++ b/typescript/packages/http/next/src/index.test.ts @@ -677,6 +677,67 @@ describe("withX402", () => { expect(body).toEqual({}); expect(response.headers.get("PAYMENT-RESPONSE")).toBe("settlement-failed-encoded"); }); + + it("passes a pattern-keyed routes config to x402HTTPResourceServer unchanged", async () => { + const mockServer = createMockHttpServer({ type: "no-payment-required" }); + setupMockCreateHttpServer(mockServer); + const handler = vi.fn().mockResolvedValue(NextResponse.json({ data: "protected" })); + const routes = { "/api/users/[id]": mockRouteConfig }; + + const wrappedHandler = withX402(handler, routes, {} as unknown as x402ResourceServer); + const response = await wrappedHandler(createMockRequest()); + + expect(vi.mocked(x402HTTPResourceServer)).toHaveBeenCalledWith(expect.anything(), routes); + expect(response.status).toBe(200); + }); + + it("passes a bare route config through for core's default wildcard handling", () => { + const mockServer = createMockHttpServer({ type: "no-payment-required" }); + setupMockCreateHttpServer(mockServer); + const handler = vi.fn().mockResolvedValue(NextResponse.json({ data: "protected" })); + + withX402(handler, mockRouteConfig, {} as unknown as x402ResourceServer); + + expect(vi.mocked(x402HTTPResourceServer)).toHaveBeenCalledWith( + expect.anything(), + mockRouteConfig, + ); + }); + + it("warns once when a pattern-keyed routes config matches no request", async () => { + const mockServer = createMockHttpServer({ type: "no-payment-required" }); + vi.mocked(mockServer.requiresPayment).mockReturnValue(false); + setupMockCreateHttpServer(mockServer); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = vi.fn().mockResolvedValue(NextResponse.json({ data: "protected" })); + + const wrappedHandler = withX402( + handler, + { "/api/users/[id]": mockRouteConfig }, + {} as unknown as x402ResourceServer, + ); + await wrappedHandler(createMockRequest()); + await wrappedHandler(createMockRequest()); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('"/api/users/[id]"'); + expect(handler).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); + + it("does not warn for a bare route config when no route matches", async () => { + const mockServer = createMockHttpServer({ type: "no-payment-required" }); + vi.mocked(mockServer.requiresPayment).mockReturnValue(false); + setupMockCreateHttpServer(mockServer); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = vi.fn().mockResolvedValue(NextResponse.json({ data: "protected" })); + + const wrappedHandler = withX402(handler, mockRouteConfig, {} as unknown as x402ResourceServer); + await wrappedHandler(createMockRequest()); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); describe("paymentProxyFromConfig", () => { diff --git a/typescript/packages/http/next/src/index.ts b/typescript/packages/http/next/src/index.ts index adf0279747..a270f4904b 100644 --- a/typescript/packages/http/next/src/index.ts +++ b/typescript/packages/http/next/src/index.ts @@ -4,7 +4,6 @@ import { x402ResourceServer, x402HTTPResourceServer, RoutesConfig, - RouteConfig, FacilitatorClient, FacilitatorResponseError, checkIfBazaarNeeded, @@ -268,7 +267,7 @@ export function paymentProxyFromConfig( * const resourceServer = new x402ResourceServer(facilitatorClient) * .register(NETWORK, new ExactEvmScheme()); * - * const httpServer = new x402HTTPResourceServer(resourceServer, { "*": routeConfig }) + * const httpServer = new x402HTTPResourceServer(resourceServer, { "/api/protected": routeConfig }) * .onProtectedRequest(requestHook); * * const handler = async (request: NextRequest) => { @@ -306,6 +305,8 @@ export function withX402FromHTTPServer( }); } + let warnedNoMatch = false; + return async (request: NextRequest): Promise> => { // Only initialize when processing a protected route try { @@ -340,6 +341,23 @@ export function withX402FromHTTPServer( // Handle the different result types switch (result.type) { case "no-payment-required": + // This wrapper protects a single handler, so with pattern-keyed routes every + // request should match one; a miss means the pattern is wrong and the handler + // is being served without payment. + if ( + !warnedNoMatch && + !("accepts" in httpServer.routes) && + !httpServer.requiresPayment(context) + ) { + warnedNoMatch = true; + const patterns = Object.keys(httpServer.routes) + .map(pattern => `"${pattern}"`) + .join(", "); + console.warn( + `[x402] Request path "${context.path}" did not match any configured route pattern (${patterns}); ` + + `the handler ran without payment. Check the route patterns passed to withX402.`, + ); + } // No payment needed, proceed directly to the route handler return routeHandler(request); @@ -396,7 +414,12 @@ export function withX402FromHTTPServer( * response (status < 400). This provides more precise control over when payments are settled. * * @param routeHandler - The API route handler function to wrap - * @param routeConfig - Payment configuration for this specific route + * @param routes - Payment configuration for this route: either a bare route config + * (matches any path, like the previous behavior) or a single-entry map keyed by the + * route's path pattern (e.g. `{ "/api/users/[id]": config }`). Prefer the keyed form + * when using bazaar discovery extensions so the resource is indexed with the correct path. + * The pattern must match the request path exactly as served (including any `basePath`); + * if it does not match, the handler runs without payment and a warning is logged. * @param server - Pre-configured x402ResourceServer instance * @param paywallConfig - Optional configuration for the built-in paywall UI * @param paywall - Optional custom paywall provider (overrides default) @@ -418,13 +441,15 @@ export function withX402FromHTTPServer( * export const GET = withX402( * handler, * { - * accepts: { - * scheme: "exact", - * payTo: "0x123...", - * price: "$0.01", - * network: "eip155:84532", + * "/api/protected": { + * accepts: { + * scheme: "exact", + * payTo: "0x123...", + * price: "$0.01", + * network: "eip155:84532", + * }, + * description: "Access to protected API", * }, - * description: "Access to protected API", * }, * server, * ); @@ -432,13 +457,12 @@ export function withX402FromHTTPServer( */ export function withX402( routeHandler: (request: NextRequest) => Promise>, - routeConfig: RouteConfig, + routes: RoutesConfig, server: x402ResourceServer, paywallConfig?: PaywallConfig, paywall?: PaywallProvider, syncFacilitatorOnStart: boolean = true, ): (request: NextRequest) => Promise> { - const routes = { "*": routeConfig }; // Create the x402 HTTP server instance with the resource server const httpServer = new x402HTTPResourceServer(server, routes); diff --git a/typescript/packages/http/next/src/lineTerminatorBypass.test.ts b/typescript/packages/http/next/src/lineTerminatorBypass.test.ts index 9305669ee8..b479371374 100644 --- a/typescript/packages/http/next/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/next/src/lineTerminatorBypass.test.ts @@ -3,6 +3,9 @@ import { NextRequest } from "next/server"; import { x402ResourceServer } from "@x402/core/server"; import { paymentProxy } from "./index"; +/** Network the routing tests price their protected routes on. */ +const TEST_NETWORK = "eip155:84532"; + /** * Reproduces CAT finding f2e83cec-d5d5-4076-bd4e-55e060d216b1 against the * real Next.js proxy (no `@x402/core/server` mocking, unlike index.test.ts). @@ -16,12 +19,35 @@ import { paymentProxy } from "./index"; describe("next end-to-end: percent-encoded line terminator under wildcard route", () => { /** * Builds a Next.js payment proxy protecting a wildcard route, backed by a - * real `x402ResourceServer` (not mocked). + * real `x402ResourceServer` (not mocked) with the `exact` scheme registered + * and a stub facilitator advertising it, so a matched route answers 402 + * instead of failing to build payment requirements. * * @returns The configured proxy handler. */ - function buildProxy() { - const resourceServer = new x402ResourceServer(); + async function buildProxy() { + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: TEST_NETWORK }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: TEST_NETWORK }), + }); + resourceServer.register(TEST_NETWORK, { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + return paymentProxy( { "/api/premium/*": { @@ -29,7 +55,7 @@ describe("next end-to-end: percent-encoded line terminator under wildcard route" scheme: "exact", payTo: "0xabc", price: "$1.00", - network: "eip155:84532", + network: TEST_NETWORK, }, }, }, @@ -42,36 +68,42 @@ describe("next end-to-end: percent-encoded line terminator under wildcard route" } it("returns 402 for a baseline wildcard match", async () => { - const res = await buildProxy()(new NextRequest("https://example.com/api/premium/report")); + const res = await ( + await buildProxy() + )(new NextRequest("https://example.com/api/premium/report")); expect(res.status).toBe(402); }); it("returns 402 even when the tail contains %E2%80%A8 (U+2028 LINE SEPARATOR)", async () => { - const res = await buildProxy()( - new NextRequest("https://example.com/api/premium/report%E2%80%A8"), - ); + const res = await ( + await buildProxy() + )(new NextRequest("https://example.com/api/premium/report%E2%80%A8")); expect(res.status).toBe(402); }); it("returns 402 even when the tail contains %E2%80%A9 (U+2029 PARAGRAPH SEPARATOR)", async () => { - const res = await buildProxy()( - new NextRequest("https://example.com/api/premium/report%E2%80%A9"), - ); + const res = await ( + await buildProxy() + )(new NextRequest("https://example.com/api/premium/report%E2%80%A9")); expect(res.status).toBe(402); }); it("returns 402 even when the tail contains %0A (encoded LF)", async () => { - const res = await buildProxy()(new NextRequest("https://example.com/api/premium/report%0A")); + const res = await ( + await buildProxy() + )(new NextRequest("https://example.com/api/premium/report%0A")); expect(res.status).toBe(402); }); it("returns 402 even when the tail contains %0D (encoded CR)", async () => { - const res = await buildProxy()(new NextRequest("https://example.com/api/premium/report%0D")); + const res = await ( + await buildProxy() + )(new NextRequest("https://example.com/api/premium/report%0D")); expect(res.status).toBe(402); }); it("returns NextResponse.next() (middleware skipped) for an unrelated path", async () => { - const res = await buildProxy()(new NextRequest("https://example.com/health")); + const res = await (await buildProxy())(new NextRequest("https://example.com/health")); // NextResponse.next() is a 200 passthrough carrying this signal header. expect(res.status).toBe(200); expect(res.headers.get("x-middleware-next")).toBe("1"); diff --git a/typescript/packages/http/paywall/CHANGELOG.md b/typescript/packages/http/paywall/CHANGELOG.md index ee509421e0..9ecd69ed34 100644 --- a/typescript/packages/http/paywall/CHANGELOG.md +++ b/typescript/packages/http/paywall/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/paywall Changelog +## 2.24.0 + +### Patch Changes + +- [6557149](https://github.com/x402-foundation/x402/commit/6557149): Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin ([#3227](https://github.com/x402-foundation/x402/pull/3227)) - Thanks [@alexander-sei](https://github.com/alexander-sei)! + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/http/paywall/package.json b/typescript/packages/http/paywall/package.json index 1e3b665777..92be7dabd1 100644 --- a/typescript/packages/http/paywall/package.json +++ b/typescript/packages/http/paywall/package.json @@ -1,6 +1,6 @@ { "name": "@x402/paywall", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/mcp/CHANGELOG.md b/typescript/packages/mcp/CHANGELOG.md index 2be571b97b..4d2688dd99 100644 --- a/typescript/packages/mcp/CHANGELOG.md +++ b/typescript/packages/mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/mcp Changelog +## 2.24.0 + +### Minor Changes + +- [8707ab7](https://github.com/x402-foundation/x402/commit/8707ab7): Withhold MCP tool content when after-handler settlement returns `{ success: false }`. ([#3246](https://github.com/x402-foundation/x402/pull/3246)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mcp/README.md b/typescript/packages/mcp/README.md index 18dd768aeb..85dd7bcbfe 100644 --- a/typescript/packages/mcp/README.md +++ b/typescript/packages/mcp/README.md @@ -64,12 +64,12 @@ await mcpServer.connect(transport); ### Client - Using Factory Function ```typescript -import { createX402MCPClient } from "@x402/mcp"; +import { createx402MCPClient } from "@x402/mcp"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; // Create client with factory (simplest approach) -const client = createX402MCPClient({ +const client = createx402MCPClient({ name: "my-agent", version: "1.0.0", schemes: [{ network: "eip155:84532", client: new ExactEvmScheme(walletAccount) }], @@ -229,7 +229,7 @@ const x402Mcp2 = wrapMCPClientWithPaymentFromConfig(mcpClient, { ## Payment Flow 1. **Client calls tool** → No payment attached -2. **Server returns 402** → PaymentRequired in structured result (see SDK Limitation below) +2. **Server returns PaymentRequired** → In a structured tool result 3. **Client creates payment** → Using x402Client 4. **Client retries with payment** → PaymentPayload in `_meta["x402/payment"]` 5. **Server verifies & executes** → Tool runs if payment valid @@ -238,21 +238,32 @@ const x402Mcp2 = wrapMCPClientWithPaymentFromConfig(mcpClient, { ## MCP SDK Limitation -The x402 MCP transport spec defines payment errors using JSON-RPC's native error format: -```json -{ "error": { "code": 402, "data": { /* PaymentRequired */ } } } -``` +The MCP TypeScript SDK v1 converts `McpError` exceptions to tool results with `isError: true`, losing the `error.data` field. To work around this, `@x402/mcp` signals payment requirements using a tool result with `isError: true`. -However, the MCP SDK converts `McpError` exceptions to tool results with `isError: true`, losing the `error.data` field. To work around this, we embed the error structure in the result content: +Per the x402 MCP transport, `PaymentRequired` is included directly in `structuredContent` and JSON-encoded in the first text content item: ```json { - "content": [{ "type": "text", "text": "{\"x402/error\": {\"code\": 402, \"data\": {...}}}" }], + "structuredContent": { "x402Version": 2, "accepts": [] }, + "content": [{ "type": "text", "text": "{\"x402Version\":2,\"accepts\":[]}" }], "isError": true } ``` -The client parses this structure to extract PaymentRequired data. This is a pragmatic workaround that maintains compatibility while we track upstream SDK improvements. +### Supported PaymentRequired Shapes + +The client accepts 4 `PaymentRequired` shapes across 2 response types. A payment requirement may be returned as either a tool result or a JSON-RPC error: + +| Order | Response Type | Error Code | Shape | Example | +|-------|---------------|------------|-------|---------| +| 1 | Tool result ⭐ | — | `result.structuredContent`
+
`result.content[0].text` (JSON-encoded) | `{`
  `result: {`
    `isError: true,`
    `structuredContent: PaymentRequired,`
    `content: [{ text: "" }]`
  `}`
`}` | +| 1 | JSON-RPC error | `-32042` | `error.data.x402` | `{`
  `error: {`
    `data: {`
      `x402: PaymentRequired`
    `}`
  `}`
`}` | +| 2 | JSON-RPC error | `-32042` | `error.data` | `{`
  `error: {`
    `data: PaymentRequired`
  `}`
`}` | +| 3 | JSON-RPC error | `402` | `error.data` | `{`
  `error: {`
    `data: PaymentRequired`
  `}`
`}` | + +⭐ Server generated & recommended shape. + +For tool results, the client checks `result.structuredContent` first, then falls back to `result.content[0].text`. For JSON-RPC errors, it uses `error.data.x402` first when available, otherwise `error.data`. ## Configuration Options @@ -260,19 +271,9 @@ The client parses this structure to extract PaymentRequired data. This is a prag | Option | Type | Default | Description | |--------|------|---------|-------------| -| `autoPayment` | `boolean` | `true` | Automatically retry with payment on 402 | +| `autoPayment` | `boolean` | `true` | Automatically retry with payment when payment is required | | `onPaymentRequested` | `function` | `() => true` | Hook for human-in-the-loop approval when payment is requested | -### X402MCPServerConfig (Factory) - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `name` | `string` | Required | MCP server name | -| `version` | `string` | Required | MCP server version | -| `facilitator` | `string \| FacilitatorClient` | Default facilitator | Facilitator for payment processing | -| `schemes` | `SchemeRegistration[]` | `[]` | Payment scheme registrations | -| `syncFacilitatorOnStart` | `boolean` | `true` | Initialize facilitator immediately | - ### MCPToolPaymentConfig | Option | Type | Required | Description | @@ -289,22 +290,19 @@ The client parses this structure to extract PaymentRequired data. This is a prag | Option | Type | Required | Description | |--------|------|----------|-------------| -| `scheme` | `string` | Yes | Payment scheme (e.g., "exact") | -| `network` | `Network` | Yes | CAIP-2 network ID (e.g., "eip155:84532") | -| `payTo` | `string` | Yes | Recipient wallet address | -| `price` | `Price` | No | Price - omit to specify per-tool | -| `maxTimeoutSeconds` | `number` | No | Payment timeout (default: 60) | -| `extra` | `object` | No | Scheme-specific parameters | -| `resource` | `object` | No | Resource metadata | +| `accepts` | `PaymentRequirements[]` | Yes | Payment requirements accepted for the tool | +| `resource` | `object` | No | Resource metadata for the tool | +| `hooks` | `object` | No | Payment lifecycle hooks | +| `extensions` | `Record` | No | x402 extensions included in the `PaymentRequired` response | ## Hooks ### Client Hooks ```typescript -const client = createX402MCPClient({...}); +const client = createx402MCPClient({...}); -// Called when a 402 is received (before payment) +// Called when payment is required (before payment) // Return { payment } to use custom payment, { abort: true } to stop client.onPaymentRequired(async ({ toolName, paymentRequired }) => { const cached = await cache.get(toolName); @@ -322,30 +320,6 @@ client.onAfterPayment(async ({ paymentPayload, settleResponse }) => { }); ``` -### Server Hooks - -```typescript -const server = createX402MCPServer({...}); - -// Called after verification, before tool execution -// Return false to abort and return 402 -server.onBeforeExecution(async ({ toolName, paymentPayload }) => { - if (isBlocked(paymentPayload.signer)) { - return false; // Aborts execution - } -}); - -// Called after tool execution, before settlement -server.onAfterExecution(async ({ toolName, result }) => { - metrics.recordExecution(toolName, result.isError); -}); - -// Called after successful settlement -server.onAfterSettlement(async ({ toolName, settlement }) => { - await logTransaction(toolName, settlement.transaction); -}); -``` - ## License Apache-2.0 diff --git a/typescript/packages/mcp/package.json b/typescript/packages/mcp/package.json index c1c2cfbf34..c5c2a1d22c 100644 --- a/typescript/packages/mcp/package.json +++ b/typescript/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@x402/mcp", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/aptos/CHANGELOG.md b/typescript/packages/mechanisms/aptos/CHANGELOG.md index 3d094ebad4..ab1e3a7996 100644 --- a/typescript/packages/mechanisms/aptos/CHANGELOG.md +++ b/typescript/packages/mechanisms/aptos/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/aptos +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on Aptos `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/aptos/package.json b/typescript/packages/mechanisms/aptos/package.json index 1e09e16788..c1a817bb76 100644 --- a/typescript/packages/mechanisms/aptos/package.json +++ b/typescript/packages/mechanisms/aptos/package.json @@ -1,6 +1,6 @@ { "name": "@x402/aptos", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/aptos/src/exact/server/scheme.ts b/typescript/packages/mechanisms/aptos/src/exact/server/scheme.ts index a9cd757bcc..5f083809b9 100644 --- a/typescript/packages/mechanisms/aptos/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/aptos/src/exact/server/scheme.ts @@ -19,7 +19,7 @@ export class ExactAptosScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/aptos/test/unit/index.test.ts b/typescript/packages/mechanisms/aptos/test/unit/index.test.ts index 3a9bbb5f14..82c2f6ff7d 100644 --- a/typescript/packages/mechanisms/aptos/test/unit/index.test.ts +++ b/typescript/packages/mechanisms/aptos/test/unit/index.test.ts @@ -44,6 +44,16 @@ describe("@x402/aptos", () => { const server = new ExactAptosServer(); expect(server.scheme).toBe("exact"); }); + + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const server = new ExactAptosServer(); + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); }); describe("ExactAptosFacilitator", () => { diff --git a/typescript/packages/mechanisms/avm/CHANGELOG.md b/typescript/packages/mechanisms/avm/CHANGELOG.md index 266ace5a91..21c0c17505 100644 --- a/typescript/packages/mechanisms/avm/CHANGELOG.md +++ b/typescript/packages/mechanisms/avm/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/avm +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on AVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/avm/package.json b/typescript/packages/mechanisms/avm/package.json index cf907a6bf4..0c7e37367b 100644 --- a/typescript/packages/mechanisms/avm/package.json +++ b/typescript/packages/mechanisms/avm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/avm", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/avm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/avm/src/exact/server/scheme.ts index 40467d3088..fead22a62b 100644 --- a/typescript/packages/mechanisms/avm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/avm/src/exact/server/scheme.ts @@ -25,7 +25,7 @@ export class ExactAvmScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/avm/test/unit/index.test.ts b/typescript/packages/mechanisms/avm/test/unit/index.test.ts index 8e9353928c..f2b9d84dc4 100644 --- a/typescript/packages/mechanisms/avm/test/unit/index.test.ts +++ b/typescript/packages/mechanisms/avm/test/unit/index.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { ExactAvmScheme as ExactAvmServerScheme } from "../../src/exact/server/scheme"; import { ALGORAND_MAINNET_CAIP2, ALGORAND_MAINNET_GENESIS_HASH, @@ -16,6 +17,16 @@ import { } from "../../src"; describe("@x402/avm", () => { + describe("ExactAvmScheme paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const server = new ExactAvmServerScheme(); + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + describe("constants", () => { it("should export correct CAIP-2 network identifiers", () => { expect(ALGORAND_MAINNET_CAIP2).toBe("algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k"); diff --git a/typescript/packages/mechanisms/concordium/CHANGELOG.md b/typescript/packages/mechanisms/concordium/CHANGELOG.md index e99580da94..6ea8b22c0e 100644 --- a/typescript/packages/mechanisms/concordium/CHANGELOG.md +++ b/typescript/packages/mechanisms/concordium/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/concordium Changelog +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on Concordium `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/concordium/package.json b/typescript/packages/mechanisms/concordium/package.json index 5c69ea807b..4370251d56 100644 --- a/typescript/packages/mechanisms/concordium/package.json +++ b/typescript/packages/mechanisms/concordium/package.json @@ -1,6 +1,6 @@ { "name": "@x402/concordium", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/concordium/src/exact/server/scheme.ts b/typescript/packages/mechanisms/concordium/src/exact/server/scheme.ts index 6955fbbd2c..2640146068 100644 --- a/typescript/packages/mechanisms/concordium/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/concordium/src/exact/server/scheme.ts @@ -23,7 +23,7 @@ export class ExactConcordiumScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; /** Custom money parser chain — tried in registration order */ diff --git a/typescript/packages/mechanisms/concordium/test/unit/concordium.test.ts b/typescript/packages/mechanisms/concordium/test/unit/concordium.test.ts index b708459ede..e407e499dd 100644 --- a/typescript/packages/mechanisms/concordium/test/unit/concordium.test.ts +++ b/typescript/packages/mechanisms/concordium/test/unit/concordium.test.ts @@ -61,6 +61,16 @@ describe("@x402/concordium", () => { expect(server.scheme).toBe("exact"); }); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const server = new ExactConcordiumServer(); + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + it("should inject feePayer from supported kind into payment requirements", async () => { const feePayer = "4FmiTW2L4RvCsSVTjFAavYvrgnPLGNj43eiwPYmbhNqtAcMbWW"; const server = new ExactConcordiumServer(); diff --git a/typescript/packages/mechanisms/evm/CHANGELOG.md b/typescript/packages/mechanisms/evm/CHANGELOG.md index cf076709c0..dfe39c0c78 100644 --- a/typescript/packages/mechanisms/evm/CHANGELOG.md +++ b/typescript/packages/mechanisms/evm/CHANGELOG.md @@ -1,5 +1,19 @@ # @x402/evm Changelog +## 2.24.0 + +### Minor Changes + +- [121c98f](https://github.com/x402-foundation/x402/commit/121c98f): Add Ethereum mainnet (eip155:1) and Avalanche C-Chain (eip155:43114) native USDC as the default stablecoin ([#3241](https://github.com/x402-foundation/x402/pull/3241)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +- [6557149](https://github.com/x402-foundation/x402/commit/6557149): Add Sei mainnet (chain ID 1329) and Sei Testnet (chain ID 1328) with native USDC as the default stablecoin ([#3227](https://github.com/x402-foundation/x402/pull/3227)) - Thanks [@alexander-sei](https://github.com/alexander-sei)! +- [44f6b17](https://github.com/x402-foundation/x402/commit/44f6b17): Align the EVM auth-capture **client** with the v1.1 spec: commerce-payments deployment selection via optional `extra.authCaptureEscrow` (v1.1 default), salt binding when `extra.receiverAuthorizer` or `extra.policy` is non-zero, and v1.0/v1.1 canonical addresses in `@x402/evm/auth-capture/client`. **Breaking (client):** the default commerce-payments deployment is now v1.1 (escrow + token collectors), so PaymentInfo hashes and signatures differ from the previous v1.0-only client unless the server sets `extra.authCaptureEscrow` to the v1.0 escrow. When `extra.receiverAuthorizer` or `extra.policy` is non-zero, collect payloads also include `saltNonce` alongside the derived `salt` (salt binding). ([#3283](https://github.com/x402-foundation/x402/pull/3283)) - Thanks [@phdargen](https://github.com/phdargen)! +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on EVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! + +### Patch Changes + +- [f41d9be](https://github.com/x402-foundation/x402/commit/f41d9be): Stop persisting untrusted batch-settlement `channelState` from PAYMENT-RESPONSE. Successful payment responses update local storage from previous state plus capped `chargedAmount` and any client-signed deposit, except when a present extra `chargedCumulativeAmount` does not equal that next cumulative — then the charge write is skipped. Onchain snapshot diffs and a missing extra cumulative do not block the write. Refunds cap the signed amount to the locally refundable balance. Failed settlements leave local state unchanged. A disagreeing server is handled by existing corrective recovery. ([#3251](https://github.com/x402-foundation/x402/pull/3251)) - Thanks [@phdargen](https://github.com/phdargen)! + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index 5bbde9f754..09fdd7eb29 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/evm", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/README.md b/typescript/packages/mechanisms/evm/src/auth-capture/README.md index 91164c6e44..dff9603fde 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/README.md +++ b/typescript/packages/mechanisms/evm/src/auth-capture/README.md @@ -16,6 +16,10 @@ See the [scheme specification](https://github.com/x402-foundation/x402/blob/main Register `AuthCaptureEvmScheme` with an `x402Client`. The client validates the requirement's `extra` fields, reconstructs the PaymentInfo struct, computes the payer-agnostic hash, and emits an ERC-3009 (default) or Permit2 payload. +When `extra.receiverAuthorizer` or `extra.policy` is non-zero, salt binding is on: the client emits a random `saltNonce` and a keccak `salt` committing to those addresses. Otherwise the wire is the unbound shape (`salt` is random 32 bytes, no `saltNonce`). + +The client resolves the commerce-payments deployment from optional `extra.authCaptureEscrow` (v1.1 default when omitted). That selects the escrow bound into the signature nonce and the collector used for `authorization.to` / `permit2Authorization.spender`. + ```typescript import { x402Client } from "@x402/core/client"; import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; @@ -51,6 +55,9 @@ Optional: | Field | Default | Notes | | --- | --- | --- | | `assetTransferMethod` | `"eip3009"` | `"eip3009"` (ERC-3009) or `"permit2"` (Uniswap Permit2). See below. | +| `authCaptureEscrow` | v1.1 escrow | Escrow address selecting the commerce-payments deployment (v1.0 or v1.1). | +| `receiverAuthorizer` | `address(0)` | When non-zero, enables salt binding. | +| `policy` | `address(0)` | When non-zero, enables salt binding. | ## Asset transfer methods @@ -74,7 +81,9 @@ Canonical `AuthCaptureEscrow` and EIP-3009 / Permit2 token collector addresses l ## Examples -- [Client example](../../../../../examples/clients/auth-capture) +- [`@x402/fetch` client example](../../../../../examples/typescript/clients/fetch/) +- [`@x402/axios` client example](../../../../../examples/typescript/clients/axios/) +- [Go HTTP client example](../../../../../examples/go/clients/http/) ## See also diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts index e42d41a5bd..4c839ef3e9 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts @@ -14,20 +14,19 @@ import type { } from "@x402/core/types"; import type { ClientEvmSigner } from "../../signer"; import { hexToBigInt } from "viem"; -import { - AUTH_CAPTURE_SCHEME, - EIP3009_TOKEN_COLLECTOR_ADDRESS, - PERMIT2_TOKEN_COLLECTOR_ADDRESS, -} from "../constants"; +import { AUTH_CAPTURE_SCHEME, resolveAuthCaptureDeployment } from "../constants"; import { computePayerAgnosticPaymentInfoHash, + deriveBoundSalt, + extraAddress, generateSalt, + isSaltBindingOn, signERC3009, signPermit2, } from "../nonce"; import type { AuthCaptureExtra, Eip3009Payload, PaymentInfoStruct, Permit2Payload } from "../types"; import { findDefaultAsset } from "../../defaultAssets"; -import { parseChainId } from "../utils"; +import { getEvmChainId } from "../../utils"; /** * Client-side implementation of the auth-capture scheme: derives the canonical @@ -105,13 +104,26 @@ export class AuthCaptureEvmScheme implements SchemeNetworkClient { ); } - const chainId = parseChainId(requirements.network); + const chainId = getEvmChainId(requirements.network); + const deployment = resolveAuthCaptureDeployment(extra.authCaptureEscrow); + if (!deployment) { + throw new Error(`Invalid authCaptureEscrow in payment requirements extra`); + } const maxAmount = requirements.amount; const nowSeconds = Math.floor(Date.now() / 1000); const preApprovalExpiry = nowSeconds + requirements.maxTimeoutSeconds; - const salt = generateSalt(); const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + const bindOn = isSaltBindingOn(extra); + const saltNonce = generateSalt(); + const salt = bindOn + ? deriveBoundSalt( + extraAddress(extra.receiverAuthorizer), + extraAddress(extra.policy), + saltNonce, + ) + : saltNonce; + // Build the canonical PaymentInfo struct (Solidity field names — do not rename). const paymentInfo: PaymentInfoStruct = { operator: extra.captureAuthorizer, @@ -129,7 +141,7 @@ export class AuthCaptureEvmScheme implements SchemeNetworkClient { }; // Payer-agnostic PaymentInfo hash — used as ERC-3009 nonce or Permit2 nonce. - const nonce = computePayerAgnosticPaymentInfoHash(chainId, paymentInfo); + const nonce = computePayerAgnosticPaymentInfoHash(chainId, paymentInfo, deployment.escrow); if (assetTransferMethod === "permit2") { const permit2Authorization: Permit2Payload["permit2Authorization"] = { @@ -138,19 +150,21 @@ export class AuthCaptureEvmScheme implements SchemeNetworkClient { token: requirements.asset as `0x${string}`, amount: maxAmount, }, - spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + spender: deployment.permit2Collector, nonce: hexToBigInt(nonce).toString(), deadline: String(preApprovalExpiry), }; const signature = await signPermit2(this.signer, permit2Authorization, chainId); - const payload: Permit2Payload = { permit2Authorization, signature, salt }; + const payload: Permit2Payload = bindOn + ? { permit2Authorization, signature, salt, saltNonce } + : { permit2Authorization, signature, salt }; return { x402Version, payload: payload as unknown as Record }; } // Default: EIP-3009 ReceiveWithAuthorization to the canonical EIP-3009 token collector. const authorization: Eip3009Payload["authorization"] = { from: this.signer.address, - to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + to: deployment.eip3009Collector, value: maxAmount, validAfter: "0", validBefore: String(preApprovalExpiry), @@ -163,7 +177,9 @@ export class AuthCaptureEvmScheme implements SchemeNetworkClient { requirements.asset as `0x${string}`, chainId, ); - const payload: Eip3009Payload = { authorization, signature, salt }; + const payload: Eip3009Payload = bindOn + ? { authorization, signature, salt, saltNonce } + : { authorization, signature, salt }; return { x402Version, payload: payload as unknown as Record }; } } diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts b/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts index af34b6080e..2e160f73bf 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts @@ -1,3 +1,5 @@ +import { getAddress, isAddress, isAddressEqual, keccak256, toBytes } from "viem"; + // Scheme identifier for the auth-capture payment scheme. export const AUTH_CAPTURE_SCHEME = "auth-capture" as const; @@ -5,13 +7,104 @@ export const AUTH_CAPTURE_SCHEME = "auth-capture" as const; // base/commerce-payments (https://github.com/base/commerce-payments). These are // the audited, live addresses listed in the upstream README and are the source // of truth for this scheme. They are universal constants, not configurable per -// merchant. -export const AUTH_CAPTURE_ESCROW_ADDRESS = +// merchant. Two CREATE2 sets are specified; default exports point at v1.1. +export type AuthCaptureDeploymentVersion = "v1.0" | "v1.1"; + +export type AuthCaptureDeployment = { + version: AuthCaptureDeploymentVersion; + escrow: `0x${string}`; + eip3009Collector: `0x${string}`; + permit2Collector: `0x${string}`; + operatorRefundCollector: `0x${string}`; +}; + +// commerce-payments v1.0 (tag v1.0.0) +export const AUTH_CAPTURE_ESCROW_V1_0_ADDRESS = "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff" as const satisfies `0x${string}`; -export const EIP3009_TOKEN_COLLECTOR_ADDRESS = +export const EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS = "0x0E3dF9510de65469C4518D7843919c0b8C7A7757" as const satisfies `0x${string}`; -export const PERMIT2_TOKEN_COLLECTOR_ADDRESS = +export const PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS = "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26" as const satisfies `0x${string}`; +export const OPERATOR_REFUND_COLLECTOR_V1_0_ADDRESS = + "0x934907bffd0901b6A21e398B9C53A4A38F02fa5d" as const satisfies `0x${string}`; + +// commerce-payments v1.1 (default) +export const AUTH_CAPTURE_ESCROW_V1_1_ADDRESS = + "0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB" as const satisfies `0x${string}`; +export const EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS = + "0xEA902B37036bcb4944577ec2101ABdEDF56EbD28" as const satisfies `0x${string}`; +export const PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS = + "0x1aacb38b16a1a8709e80746825E53A0C9Cae9b70" as const satisfies `0x${string}`; +export const OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS = + "0x6a1ADdEEb4bD9c5811a613e20c172b6CE61A4aaB" as const satisfies `0x${string}`; + +/** Default deployment aliases (v1.1). */ +export const AUTH_CAPTURE_ESCROW_ADDRESS = AUTH_CAPTURE_ESCROW_V1_1_ADDRESS; +export const EIP3009_TOKEN_COLLECTOR_ADDRESS = EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS; +export const PERMIT2_TOKEN_COLLECTOR_ADDRESS = PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS; +export const OPERATOR_REFUND_COLLECTOR_ADDRESS = OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS; + +export const AUTH_CAPTURE_DEPLOYMENT_V1_0: AuthCaptureDeployment = { + version: "v1.0", + escrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + eip3009Collector: EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS, + permit2Collector: PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS, + operatorRefundCollector: OPERATOR_REFUND_COLLECTOR_V1_0_ADDRESS, +}; + +export const AUTH_CAPTURE_DEPLOYMENT_V1_1: AuthCaptureDeployment = { + version: "v1.1", + escrow: AUTH_CAPTURE_ESCROW_V1_1_ADDRESS, + eip3009Collector: EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS, + permit2Collector: PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS, + operatorRefundCollector: OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS, +}; + +/** + * Resolve the commerce-payments deployment from optional `extra.authCaptureEscrow`. + * Absent or the v1.1 escrow selects v1.1; the v1.0 escrow selects v1.0. + * + * @param escrow - Optional escrow address from extra (`authCaptureEscrow`). + * @returns Known v1.0 or v1.1 deployment, or undefined when the address is unknown. + */ +export function resolveAuthCaptureDeployment(escrow?: string): AuthCaptureDeployment | undefined { + if (escrow === undefined || escrow === "") { + return AUTH_CAPTURE_DEPLOYMENT_V1_1; + } + if (!isAddress(escrow)) { + return undefined; + } + const normalized = getAddress(escrow); + if ( + isAddressEqual(normalized, AUTH_CAPTURE_ESCROW_V1_1_ADDRESS) || + isAddressEqual(normalized, AUTH_CAPTURE_ESCROW_ADDRESS) + ) { + return AUTH_CAPTURE_DEPLOYMENT_V1_1; + } + if (isAddressEqual(normalized, AUTH_CAPTURE_ESCROW_V1_0_ADDRESS)) { + return AUTH_CAPTURE_DEPLOYMENT_V1_0; + } + return undefined; +} + +/** Default max gas for a custom-operator collect relay (`authorize` or `charge`). */ +export const DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT = 1_000_000n; + +// Domain tag for the bound-salt derivation. Encoded as the first word of +// `abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce)` +// so a value produced as a salt commitment can never be read as a signature nonce. +export const SALT_BINDING_TYPEHASH = keccak256( + toBytes( + "x402AuthCaptureSaltBinding(address receiverAuthorizer,address policy,uint256 saltNonce)", + ), +); + +// Shared EIP-712 domain for every operator type. `verifyingContract` is the +// capture authorizer (PaymentInfo.operator), not a scheme-wide address. +export const OPERATOR_EIP712_DOMAIN = { + name: "x402 Auth Capture Operator", + version: "1", +} as const; // ERC-3009 ReceiveWithAuthorization EIP-712 types export const RECEIVE_AUTHORIZATION_TYPES = { @@ -38,3 +131,99 @@ export const PERMIT2_TRANSFER_FROM_TYPES = { { name: "amount", type: "uint256" }, ], } as const; + +// Operator EIP-712 types for facilitator-relayed charge and lifecycle. +// v1.0 operator EIP-712 types (feeBps on charge/capture). +export const CHARGE_TYPES_V1_0 = { + Charge: [ + { name: "paymentInfoHash", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorDataHash", type: "bytes32" }, + { name: "feeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + ], +} as const; + +export const CAPTURE_TYPES_V1_0 = { + Capture: [ + { name: "paymentInfoHash", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "feeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + { name: "expectedCapturableAmount", type: "uint256" }, + { name: "expectedRefundableAmount", type: "uint256" }, + ], +} as const; + +// v1.1 operator EIP-712 types (absolute feeAmount on charge/capture). +export const CHARGE_TYPES_V1_1 = { + Charge: [ + { name: "paymentInfoHash", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorDataHash", type: "bytes32" }, + { name: "feeAmount", type: "uint256" }, + { name: "feeReceiver", type: "address" }, + ], +} as const; + +export const CAPTURE_TYPES_V1_1 = { + Capture: [ + { name: "paymentInfoHash", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "feeAmount", type: "uint256" }, + { name: "feeReceiver", type: "address" }, + { name: "expectedCapturableAmount", type: "uint256" }, + { name: "expectedRefundableAmount", type: "uint256" }, + ], +} as const; + +/** Default v1.1 charge/capture EIP-712 types. */ +export const CHARGE_TYPES = CHARGE_TYPES_V1_1; +export const CAPTURE_TYPES = CAPTURE_TYPES_V1_1; + +export const VOID_TYPES = { + Void: [{ name: "paymentInfoHash", type: "bytes32" }], +} as const; + +export const REFUND_TYPES = { + Refund: [ + { name: "paymentInfoHash", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "expectedCapturableAmount", type: "uint256" }, + { name: "expectedRefundableAmount", type: "uint256" }, + ], +} as const; + +/** + * Operator Charge EIP-712 types for a resolved deployment. + * + * @param deployment - Resolved escrow addresses and version. + * @returns Charge typed-data fields matching the deployment fee encoding. + */ +export function chargeTypesForDeployment(deployment: AuthCaptureDeployment) { + return deployment.version === "v1.0" ? CHARGE_TYPES_V1_0 : CHARGE_TYPES_V1_1; +} + +/** + * Operator Capture EIP-712 types for a resolved deployment. + * + * @param deployment - Resolved escrow addresses and version. + * @returns Capture typed-data fields matching the deployment fee encoding. + */ +export function captureTypesForDeployment(deployment: AuthCaptureDeployment) { + return deployment.version === "v1.0" ? CAPTURE_TYPES_V1_0 : CAPTURE_TYPES_V1_1; +} + +/** + * Integer fee from amount and bps (same division the escrow uses). + * + * @param amount - Principal amount in atomic token units. + * @param feeBps - Fee in basis points. + * @returns Absolute fee amount using escrow integer division. + */ +export function feeAmountFromBps(amount: bigint, feeBps: number): bigint { + return (amount * BigInt(feeBps)) / 10_000n; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts b/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts index d157ade5a0..55d2d5c2e7 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts @@ -2,13 +2,23 @@ * Nonce computation, salt generation, and signing helpers. */ -import { encodeAbiParameters, getAddress, keccak256, toHex, zeroAddress } from "viem"; -import type { ClientEvmSigner } from "../signer"; +import { + encodeAbiParameters, + getAddress, + isAddress, + isAddressEqual, + keccak256, + toHex, + zeroAddress, +} from "viem"; +import type { ClientEvmSigner, FacilitatorEvmSigner } from "../signer"; +import { verifyTypedDataSignature } from "../shared/verifySignature"; import { PERMIT2_ADDRESS } from "../constants"; import { AUTH_CAPTURE_ESCROW_ADDRESS, PERMIT2_TRANSFER_FROM_TYPES, RECEIVE_AUTHORIZATION_TYPES, + SALT_BINDING_TYPEHASH, } from "./constants"; import type { AuthCaptureExtra, Eip3009Payload, PaymentInfoStruct, Permit2Payload } from "./types"; @@ -22,23 +32,21 @@ const PAYMENT_INFO_TYPEHASH = keccak256( ); /** - * Compute the payer-agnostic PaymentInfo hash that auth-capture uses as both - * the ERC-3009 nonce (`bytes32`) and the Permit2 nonce (`uint256`, via the - * same 32 bytes interpreted as an integer). The payer field is zeroed before - * hashing so the facilitator can reconstruct the same hash on the verify side - * without knowing payer identity in advance. - * - * Freshness comes from `paymentInfo.salt`; generate a new salt per signing - * call via `generateSalt`. Identical extras + same salt would collide across - * payers. + * Encode PaymentInfo the way AuthCaptureEscrow.getHash does: typehash plus + * struct fields, then wrap with chain id and escrow address. * * @param chainId - EVM chain id; binds the hash to a specific chain. - * @param paymentInfo - The reconstructed PaymentInfo struct (canonical Solidity field names). - * @returns The 32-byte hash to use as the nonce on the wire. + * @param paymentInfo - Canonical PaymentInfo struct. + * @param payer - Payer encoded in the struct hash. Pass `zeroAddress` for the + * payer-agnostic signature nonce. + * @param escrowAddress - AuthCaptureEscrow bound into the outer hash. + * @returns The 32-byte hash. */ -export function computePayerAgnosticPaymentInfoHash( +function hashPaymentInfo( chainId: number, paymentInfo: PaymentInfoStruct, + payer: `0x${string}`, + escrowAddress: `0x${string}` = AUTH_CAPTURE_ESCROW_ADDRESS, ): `0x${string}` { const paymentInfoEncoded = encodeAbiParameters( [ @@ -59,7 +67,7 @@ export function computePayerAgnosticPaymentInfoHash( [ PAYMENT_INFO_TYPEHASH, paymentInfo.operator, - zeroAddress, + payer, paymentInfo.receiver, paymentInfo.token, BigInt(paymentInfo.maxAmount), @@ -80,12 +88,53 @@ export function computePayerAgnosticPaymentInfoHash( { name: "escrow", type: "address" }, { name: "paymentInfoHash", type: "bytes32" }, ], - [BigInt(chainId), AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash], + [BigInt(chainId), escrowAddress, paymentInfoHash], ); return keccak256(outerEncoded); } +/** + * Compute the payer-agnostic PaymentInfo hash that auth-capture uses as both + * the ERC-3009 nonce (`bytes32`) and the Permit2 nonce (`uint256`, via the + * same 32 bytes interpreted as an integer). The payer field is zeroed before + * hashing so the facilitator can reconstruct the same hash on the verify side + * without knowing payer identity in advance. + * + * Freshness comes from `paymentInfo.salt`; generate a new salt per signing + * call via `generateSalt`. Identical extras + same salt would collide across + * payers. + * + * @param chainId - EVM chain id; binds the hash to a specific chain. + * @param paymentInfo - The reconstructed PaymentInfo struct (canonical Solidity field names). + * @param escrowAddress - AuthCaptureEscrow bound into the outer hash. + * @returns The 32-byte hash to use as the nonce on the wire. + */ +export function computePayerAgnosticPaymentInfoHash( + chainId: number, + paymentInfo: PaymentInfoStruct, + escrowAddress: `0x${string}` = AUTH_CAPTURE_ESCROW_ADDRESS, +): `0x${string}` { + return hashPaymentInfo(chainId, paymentInfo, zeroAddress, escrowAddress); +} + +/** + * Compute AuthCaptureEscrow.getHash(paymentInfo) locally. Differs from the + * signature nonce by encoding the real payer rather than address(0). + * + * @param chainId - EVM chain id. + * @param paymentInfo - PaymentInfo with the real payer. + * @param escrowAddress - AuthCaptureEscrow bound into the outer hash. + * @returns The escrow payment identifier (`paymentInfoHash`). + */ +export function computePaymentInfoHash( + chainId: number, + paymentInfo: PaymentInfoStruct, + escrowAddress: `0x${string}` = AUTH_CAPTURE_ESCROW_ADDRESS, +): `0x${string}` { + return hashPaymentInfo(chainId, paymentInfo, paymentInfo.payer, escrowAddress); +} + /** * Sign an ERC-3009 `ReceiveWithAuthorization` over the supplied authorization * fields. The EIP-712 domain is bound to the **token contract** (not the @@ -130,6 +179,52 @@ export async function signERC3009( }); } +/** + * Verify an ERC-3009 `ReceiveWithAuthorization` signature against the supplied + * authorization fields. Mirrors `signERC3009`: the EIP-712 domain is bound to + * the **token contract**, with `name`/`version` from `extra`. + * + * Routed through {@link verifyTypedDataSignature} rather than + * `signer.verifyTypedData` so pre-verify matches on-chain SignatureChecker + * semantics: no ECDSA fallback when EIP-1271 returns failure, which otherwise + * accepts signatures the token contract rejects for any payer with code. + * + * @param signer - Facilitator signer used for `eth_getCode` / `isValidSignature`. + * @param authorization - The ERC-3009 authorization to verify. + * @param signature - The signature blob from the payer. + * @param extra - Carries the token EIP-712 domain `name`, `version`, and the chain id. + * @param tokenAddress - Address of the token contract (verifyingContract in the domain). + * @returns True if the signature is valid for `authorization.from`; false otherwise. + */ +export async function verifyERC3009Signature( + signer: FacilitatorEvmSigner, + authorization: Eip3009Payload["authorization"], + signature: `0x${string}`, + extra: AuthCaptureExtra & { chainId: number }, + tokenAddress: `0x${string}`, +): Promise { + return verifyTypedDataSignature(signer, { + address: getAddress(authorization.from), + domain: { + name: extra.name, + version: extra.version, + chainId: extra.chainId, + verifyingContract: getAddress(tokenAddress), + }, + types: RECEIVE_AUTHORIZATION_TYPES, + primaryType: "ReceiveWithAuthorization", + message: { + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }, + signature, + }); +} + /** * Sign a Permit2 `PermitTransferFrom` over the supplied permit fields. Domain * is bound to the canonical Permit2 contract. No witness struct is needed — @@ -171,6 +266,46 @@ export async function signPermit2( }); } +/** + * Verify a Permit2 `PermitTransferFrom` signature against the supplied permit + * fields. Mirrors `signPermit2`: domain bound to the canonical Permit2 + * contract. Routed through {@link verifyTypedDataSignature} so pre-verify + * matches Permit2's own `SignatureVerification` semantics. + * + * @param signer - Facilitator signer used for `eth_getCode` / `isValidSignature`. + * @param permit - The Permit2 PermitTransferFrom message to verify. + * @param signature - The signature blob from the payer. + * @param chainId - EVM chain id (chainId in the Permit2 domain). + * @returns True if the signature is valid for `permit.from`; false otherwise. + */ +export async function verifyPermit2Signature( + signer: FacilitatorEvmSigner, + permit: Permit2Payload["permit2Authorization"], + signature: `0x${string}`, + chainId: number, +): Promise { + return verifyTypedDataSignature(signer, { + address: getAddress(permit.from), + domain: { + name: "Permit2", + chainId, + verifyingContract: PERMIT2_ADDRESS, + }, + types: PERMIT2_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message: { + permitted: { + token: getAddress(permit.permitted.token), + amount: BigInt(permit.permitted.amount), + }, + spender: getAddress(permit.spender), + nonce: BigInt(permit.nonce), + deadline: BigInt(permit.deadline), + }, + signature, + }); +} + /** * Generate a fresh cryptographically-random 32-byte salt. MUST be called once * per signing request — never reuse across requests. Freshness is required @@ -184,3 +319,78 @@ export function generateSalt(): `0x${string}` { crypto.getRandomValues(bytes); return toHex(bytes); } + +/** + * Zero-pad a 0x-prefixed hex integer to a full 32-byte word. + * + * @param value - Hex string, with or without `0x`. + * @returns 66-character `0x`-prefixed hex. + */ +export function normalizeBytes32(value: string): `0x${string}` { + const hex = value.startsWith("0x") || value.startsWith("0X") ? value.slice(2) : value; + if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length === 0 || hex.length > 64) { + throw new Error(`Invalid bytes32: ${value}`); + } + return `0x${hex.padStart(64, "0").toLowerCase()}` as `0x${string}`; +} + +/** + * Treat absent or invalid values as the zero address; otherwise checksum. + * + * @param value - Candidate address from extra or config. + * @returns Checksummed address, or the zero address when absent. + */ +export function extraAddress(value: string | undefined): `0x${string}` { + if (!value || !isAddress(value)) return zeroAddress; + return getAddress(value); +} + +/** + * Whether `value` is a non-zero EVM address. + * + * @param value - Candidate address. + * @returns True when `value` is a valid address other than address(0). + */ +export function isNonZeroAddress(value: string | undefined): boolean { + if (!value || !isAddress(value)) return false; + return !isAddressEqual(getAddress(value), zeroAddress); +} + +/** + * Salt binding is on when either `receiverAuthorizer` or `policy` is non-zero. + * Absent fields are the zero address. + * + * @param extra - Wire extra (or a subset with the two address fields). + * @param extra.receiverAuthorizer - Optional receiver authorizer; absent is the zero address. + * @param extra.policy - Optional policy contract; absent is the zero address. + * @returns True when the client must emit `saltNonce` and a keccak salt. + */ +export function isSaltBindingOn(extra: { receiverAuthorizer?: string; policy?: string }): boolean { + return isNonZeroAddress(extra.receiverAuthorizer) || isNonZeroAddress(extra.policy); +} + +/** + * Bound PaymentInfo.salt: keccak256(abi.encode(SALT_BINDING_TYPEHASH, + * receiverAuthorizer, policy, saltNonce)). + * + * @param receiverAuthorizer - extra.receiverAuthorizer, or the zero address. + * @param policy - extra.policy, or the zero address. + * @param saltNonce - Client's random 32-byte contribution. + * @returns 32-byte salt commitment. + */ +export function deriveBoundSalt( + receiverAuthorizer: `0x${string}`, + policy: `0x${string}`, + saltNonce: `0x${string}`, +): `0x${string}` { + const encoded = encodeAbiParameters( + [ + { name: "typehash", type: "bytes32" }, + { name: "receiverAuthorizer", type: "address" }, + { name: "policy", type: "address" }, + { name: "saltNonce", type: "uint256" }, + ], + [SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, BigInt(saltNonce)], + ); + return keccak256(encoded); +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/types.ts b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts index 8df0e312c2..495019c201 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/types.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts @@ -2,45 +2,176 @@ * auth-capture wire-format types. * * Spec-level field names (captureAuthorizer, captureDeadline, refundDeadline, - * feeRecipient) live here at the extra/wire layer. The on-chain PaymentInfo + * feeRecipient) live here at the extra/wire layer. The onchain PaymentInfo * struct keeps the canonical Solidity field names (operator, authorizationExpiry, * refundExpiry, feeReceiver) so the EIP-712 typehash stays byte-identical with * the AuthCaptureEscrow contract. * * Salt is NOT in extra. It is generated client-side per signing call and rides - * on the payload alongside the signature. + * on the payload alongside the signature. When salt binding is on, `saltNonce` + * is added beside `salt`. */ +import type { PendingSettlementStore } from "@x402/core/facilitator"; +import type { TypedData } from "viem"; import type { AssetTransferMethod } from "../types"; -// AuthCaptureExtra — fields in PaymentRequirements.extra. -// -// Fee-policy fields (minFeeBps, maxFeeBps, feeRecipient) are all required. -// No implicit defaults: a merchant who wants no minimum fee writes -// `minFeeBps: 0` explicitly. This forces fee policy to be a conscious choice -// on the wire and avoids "did they mean 0 or did they forget?" ambiguity. +export type AuthCapturePaymentFlow = "escrow" | "authorization"; +export type AuthCaptureCaptureMode = "sync" | "deferred"; +export type AuthCaptureOperatorType = "delegated" | "custom" | "policy"; + +export interface AuthorizerSigner { + address: `0x${string}`; + signTypedData(params: { + domain: Record; + types: TypedData; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} + +type AuthCaptureLifecycleExtra = + | { + paymentFlow?: "escrow"; + captureMode?: AuthCaptureCaptureMode; + receiverAuthorizer?: `0x${string}`; + } + | { paymentFlow: "authorization"; captureMode?: never; receiverAuthorizer?: `0x${string}` }; + +type AuthCaptureDeadlineExtra = + | { + captureDeadline: number; + refundDeadline: number; + captureDeadlineSeconds?: never; + refundDeadlineSeconds?: never; + } + | { + captureDeadlineSeconds: number; + refundDeadlineSeconds: number; + captureDeadline?: never; + refundDeadline?: never; + }; + +type AuthCaptureMerchantFeesExtra = { + feeRecipient: `0x${string}`; + minFeeBps: number; + maxFeeBps: number; + name: string; + version: string; + policy?: `0x${string}`; + assetTransferMethod?: AssetTransferMethod; +}; + +type AuthCaptureDelegatedRouteExtra = AuthCaptureMerchantFeesExtra & { + operatorType?: "delegated"; + /** Omitted for delegated routes when the facilitator advertises it on `/supported` extra. */ + captureAuthorizer?: `0x${string}`; +}; + +type AuthCaptureCustomRouteExtra = AuthCaptureMerchantFeesExtra & { + operatorType: "custom"; + captureAuthorizer: `0x${string}`; + /** Collect-only: the facilitator relays no lifecycle, so sync has nothing to finalize with. */ + captureMode?: "deferred"; +}; + +/** + * Merchant-authored route extra. Correlated optionals are unions so forbidden + * combinations (captureMode on an authorization route, sync capture on a custom + * operator, mixed absolute/relative deadlines) are unrepresentable when the + * literal is checked with `satisfies AuthCaptureRouteExtra`. Escrow sync derives + * `receiverAuthorizer` from the scheme signer when the route omits it. + */ +export type AuthCaptureRouteExtra = (AuthCaptureDelegatedRouteExtra | AuthCaptureCustomRouteExtra) & + AuthCaptureLifecycleExtra & + AuthCaptureDeadlineExtra; + +/** + * Wire extra after `enhancePaymentRequirements`. Deadlines are always absolute. + * `paymentFlow` / `captureMode` / `receiverAuthorizer` are independent optionals + * here because the facilitator validates untrusted `Record`. + */ export interface AuthCaptureExtra { - // Required - // The only address allowed to call authorize/capture/void/refund/charge on - // AuthCaptureEscrow (each of those is gated by onlySender(paymentInfo.operator)) - // — i.e., it must be msg.sender of the "Authorize" call. In x402's - // facilitator-submits flow that means either the facilitator's EOA, or any - // smart contract that ultimately calls escrow (arbiter with dispute logic, - // multisig, etc.). Independent of assetTransferMethod — applies to both - // EIP-3009 and Permit2. - captureAuthorizer: `0x${string}`; // formerly `operator` in commerce-payments - captureDeadline: number; // absolute Unix seconds; capture must occur before this - refundDeadline: number; // absolute Unix seconds; refunds allowed until this - feeRecipient: `0x${string}`; // address that receives the fee portion (renamed from feeReceiver) - minFeeBps: number; // floor on the captureAuthorizer's fee; 0 = no minimum - maxFeeBps: number; // cap on the captureAuthorizer's fee - name: string; // EIP-712 token-domain name (e.g., "USDC") - version: string; // EIP-712 token-domain version (e.g., "2") - // Optional - autoCapture?: boolean; // default: false. true → facilitator calls charge(), false → authorize() - assetTransferMethod?: AssetTransferMethod; // default: 'eip3009' + captureAuthorizer: `0x${string}`; + captureDeadline: number; + refundDeadline: number; + feeRecipient: `0x${string}`; + minFeeBps: number; + maxFeeBps: number; + name: string; + version: string; + authCaptureEscrow?: `0x${string}`; + paymentFlow?: AuthCapturePaymentFlow; + captureMode?: AuthCaptureCaptureMode; + receiverAuthorizer?: `0x${string}`; + policy?: `0x${string}`; + operatorType?: AuthCaptureOperatorType; + assetTransferMethod?: AssetTransferMethod; } +export type AuthCaptureFeeTerms = { + feeRecipient: `0x${string}`; + minFeeBps: number; + maxFeeBps: number; +}; + +export type OperatorAllowlistEntry = { + address: "*" | `0x${string}`; + operatorType: "custom"; +}; + +export type AuthCaptureFacilitatorConfig = { + feeTerms?: AuthCaptureFeeTerms; + operators?: OperatorAllowlistEntry[]; + receiverAuthorizer?: `0x${string}`; + /** + * Max gas for a custom-operator collect relay (`authorize` or `charge`). + * Used as the verify reject threshold and as a hard broadcast ceiling. + * + * @default DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT (1_000_000) + */ + customOperatorAuthorizeGasLimit?: bigint; + /** + * Allowlist of ERC-6492 preparation targets (hex strings, case-insensitive) the + * facilitator accepts for an undeployed payer wallet. + * + * A counterfactual payer has no `isValidSignature` to call, so its signature can only + * be validated by the onchain simulation, where the canonical token collector deploys + * the wallet before checking the inner signature. The collector makes that preparation + * call through Multicall3, so the facilitator is never its sender; the allowlist exists + * to bound the gas an unknown preparation target can burn. An empty or omitted list + * rejects every counterfactual payment. + * + * @default [] + */ + eip6492AllowedFactories?: string[]; + /** + * When true, the facilitator relays `type: "refund"` for `"delegated"` + * operators. Requires an out-of-band funding agreement: refunds pull tokens + * from `PaymentInfo.operator`. With a rotated submitter set, every address + * in the rotation must be funded and approved — `OperatorRefundCollector` + * pulls with `safeTransferFrom(token, PaymentInfo.operator, ...)`. + * + * Each submitter also gets its own CREATE2 `TokenStore` on first authorize, + * so N keys mean N deployment costs and escrow-held balances split N ways. + */ + refundFunding?: boolean; + /** + * Lets a retried settle for the same payload reconcile against an + * already-broadcast transaction instead of re-verifying and + * re-broadcasting (see {@link PendingSettlementStore}). Defaults to a + * fresh in-memory store shared across all settle calls on this scheme + * instance. + */ + pendingSettlementStore?: PendingSettlementStore; +}; + +export type CaptureOptions = { + feeReceiver?: `0x${string}`; + feeBps?: number; + feeAmount?: string; +} & ({ amount?: string; voidRemainder?: false } | { amount: string; voidRemainder: true }); + /** * Type guard for AuthCaptureExtra. Checks the structural shape an auth-capture * scheme requires inside `PaymentRequirements.extra`: every spec-mandated @@ -64,109 +195,389 @@ export function isAuthCaptureExtra(value: unknown): value is AuthCaptureExtra { ); } -// EIP-3009 payload — ReceiveWithAuthorization to the canonical EIP-3009 token collector. -export interface Eip3009Payload { - authorization: { - from: `0x${string}`; - to: `0x${string}`; // EIP3009_TOKEN_COLLECTOR_ADDRESS - value: string; - validAfter: string; - validBefore: string; // = preApprovalExpiry - nonce: `0x${string}`; // = payer-agnostic PaymentInfo hash +type ChargeCompletionV1_0 = { + amount: string; + feeBps: number; + feeReceiver: `0x${string}`; + authorizerSignature: `0x${string}`; +}; + +type ChargeCompletionV1_1 = { + amount: string; + feeAmount: string; + feeReceiver: `0x${string}`; + authorizerSignature: `0x${string}`; +}; + +type ChargeCompletion = ChargeCompletionV1_0 | ChargeCompletionV1_1; + +type NoChargeCompletion = { + amount?: never; + feeBps?: never; + feeAmount?: never; + feeReceiver?: never; + authorizerSignature?: never; +}; + +type CollectEnvelope = + | (TAuth & { salt: `0x${string}`; saltNonce?: never; type?: never } & NoChargeCompletion) + | (TAuth & { salt: `0x${string}`; saltNonce: `0x${string}`; type?: never } & NoChargeCompletion) + | (TAuth & { salt: `0x${string}`; saltNonce: `0x${string}`; type?: never } & ChargeCompletion); + +export type Eip3009Authorization = { + from: `0x${string}`; + to: `0x${string}`; + value: string; + validAfter: string; + validBefore: string; + nonce: `0x${string}`; +}; + +export type Permit2Authorization = { + from: `0x${string}`; + permitted: { + token: `0x${string}`; + amount: string; }; + spender: `0x${string}`; + nonce: string; + deadline: string; +}; + +export type Eip3009Payload = CollectEnvelope<{ + authorization: Eip3009Authorization; signature: `0x${string}`; - salt: `0x${string}`; // bytes32, fresh per request, used to reconstruct PaymentInfo +}>; + +export type Permit2Payload = CollectEnvelope<{ + permit2Authorization: Permit2Authorization; + signature: `0x${string}`; +}>; + +export type AuthCaptureCollectPayload = Eip3009Payload | Permit2Payload; + +type LifecycleBase = { + paymentInfo: PaymentInfoStruct; + saltNonce: `0x${string}`; + authorizerSignature: `0x${string}`; +}; + +export type CapturePayload = LifecycleBase & { + type: "capture"; + amount: string; + feeReceiver: `0x${string}`; + expectedCapturableAmount: string; + expectedRefundableAmount: string; + voidAuthorizerSignature?: `0x${string}`; +} & ({ feeBps: number; feeAmount?: never } | { feeAmount: string; feeBps?: never }); + +export type VoidPayload = LifecycleBase & { + type: "void"; + voidAuthorizerSignature?: never; +}; + +export type RefundPayload = LifecycleBase & { + type: "refund"; + amount: string; + expectedCapturableAmount: string; + expectedRefundableAmount: string; + voidAuthorizerSignature?: never; +}; + +export type AuthCaptureLifecyclePayload = CapturePayload | VoidPayload | RefundPayload; + +export type AuthCapturePayload = AuthCaptureCollectPayload | AuthCaptureLifecyclePayload; + +/** + * True when `value` has a collect-payload `type` discriminant of `capture` / + * `void` / `refund`. Field-level validation happens in the lifecycle verifier. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` names a lifecycle operation. + */ +export function isLifecyclePayload(value: unknown): value is AuthCaptureLifecyclePayload { + if (typeof value !== "object" || value === null) return false; + const type = (value as Record).type; + return type === "capture" || type === "void" || type === "refund"; } /** - * Type guard for an EIP-3009-shaped auth-capture payload. Checks for an - * `authorization` object plus the required `signature` and `salt` fields; - * field-level validation happens later in `verify()`. + * Type guard for a capture lifecycle payload. * * @param value - Candidate payment payload from the wire. - * @returns True if `value` has the EIP-3009 envelope shape. + * @returns True if `value` is a capture envelope with the required fields. */ -export function isEip3009Payload(value: unknown): value is Eip3009Payload { +export function isCapturePayload(value: unknown): value is CapturePayload { + if (!isLifecyclePayload(value) || value.type !== "capture") return false; + const v = value as Record; + const hasFeeBps = typeof v.feeBps === "number"; + const hasFeeAmount = typeof v.feeAmount === "string"; + if (hasFeeBps === hasFeeAmount) return false; + return ( + isPaymentInfoStruct(v.paymentInfo) && + typeof v.saltNonce === "string" && + typeof v.amount === "string" && + typeof v.feeReceiver === "string" && + typeof v.expectedCapturableAmount === "string" && + typeof v.expectedRefundableAmount === "string" && + typeof v.authorizerSignature === "string" && + (v.voidAuthorizerSignature === undefined || typeof v.voidAuthorizerSignature === "string") + ); +} + +/** + * Type guard for a void lifecycle payload. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` is a void envelope with the required fields. + */ +export function isVoidPayload(value: unknown): value is VoidPayload { + if (!isLifecyclePayload(value) || value.type !== "void") return false; + const v = value as Record; + return ( + isPaymentInfoStruct(v.paymentInfo) && + typeof v.saltNonce === "string" && + typeof v.authorizerSignature === "string" && + v.voidAuthorizerSignature === undefined + ); +} + +/** + * Type guard for a refund lifecycle payload. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` is a refund envelope with the required fields. + */ +export function isRefundPayload(value: unknown): value is RefundPayload { + if (!isLifecyclePayload(value) || value.type !== "refund") return false; + const v = value as Record; + return ( + isPaymentInfoStruct(v.paymentInfo) && + typeof v.saltNonce === "string" && + typeof v.amount === "string" && + typeof v.expectedCapturableAmount === "string" && + typeof v.expectedRefundableAmount === "string" && + typeof v.authorizerSignature === "string" && + v.voidAuthorizerSignature === undefined + ); +} + +/** + * Type guard for the onchain PaymentInfo struct shape. + * + * @param value - Candidate struct from a lifecycle payload. + * @returns True if every PaymentInfo field is present with the expected type. + */ +function isPaymentInfoStruct(value: unknown): value is PaymentInfoStruct { if (typeof value !== "object" || value === null) return false; const v = value as Record; return ( - "authorization" in v && - typeof v.authorization === "object" && - v.authorization !== null && - typeof v.signature === "string" && + typeof v.operator === "string" && + typeof v.payer === "string" && + typeof v.receiver === "string" && + typeof v.token === "string" && + typeof v.maxAmount === "string" && + typeof v.preApprovalExpiry === "number" && + typeof v.authorizationExpiry === "number" && + typeof v.refundExpiry === "number" && + typeof v.minFeeBps === "number" && + typeof v.maxFeeBps === "number" && + typeof v.feeReceiver === "string" && typeof v.salt === "string" ); } -// Permit2 payload — PermitTransferFrom to the canonical Permit2 token collector. -export interface Permit2Payload { - permit2Authorization: { - from: `0x${string}`; - permitted: { - token: `0x${string}`; - amount: string; +/** + * Whether `value` is a 0x-prefixed hex string. + * + * @param value - Candidate wire field. + * @returns True when `value` is hex. + */ +function isHexString(value: unknown): value is `0x${string}` { + return typeof value === "string" && value.startsWith("0x"); +} + +/** + * Parse the four charge-completion fields as an all-or-none group. + * + * @param v - Collect payload fields. + * @returns ChargeCompletion when all four are present and well-typed; undefined when all are absent. + */ +function readChargeCompletion(v: Record): ChargeCompletion | undefined { + const hasAny = + "amount" in v || + "feeBps" in v || + "feeAmount" in v || + "feeReceiver" in v || + "authorizerSignature" in v; + if (!hasAny) return undefined; + if ( + typeof v.amount === "string" && + typeof v.feeBps === "number" && + v.feeAmount === undefined && + typeof v.feeReceiver === "string" && + typeof v.authorizerSignature === "string" + ) { + return { + amount: v.amount, + feeBps: v.feeBps, + feeReceiver: v.feeReceiver as `0x${string}`, + authorizerSignature: v.authorizerSignature as `0x${string}`, }; - spender: `0x${string}`; // PERMIT2_TOKEN_COLLECTOR_ADDRESS - nonce: string; // uint256 string, = uint256(payer-agnostic PaymentInfo hash) - deadline: string; // = preApprovalExpiry - }; - signature: `0x${string}`; - salt: `0x${string}`; // bytes32, fresh per request, used to reconstruct PaymentInfo + } + if ( + typeof v.amount === "string" && + typeof v.feeAmount === "string" && + v.feeBps === undefined && + typeof v.feeReceiver === "string" && + typeof v.authorizerSignature === "string" + ) { + return { + amount: v.amount, + feeAmount: v.feeAmount, + feeReceiver: v.feeReceiver as `0x${string}`, + authorizerSignature: v.authorizerSignature as `0x${string}`, + }; + } + return undefined; +} + +/** + * Parse collect `salt` and optional `saltNonce`. + * + * @param v - Collect payload fields. + * @returns Unbound or bound salt pair, or undefined when malformed. + */ +function collectExtras( + v: Record, +): + | { salt: `0x${string}`; saltNonce?: never } + | { salt: `0x${string}`; saltNonce: `0x${string}` } + | undefined { + if (!isHexString(v.salt)) return undefined; + if (v.saltNonce === undefined) { + return { salt: v.salt }; + } + if (!isHexString(v.saltNonce)) return undefined; + return { salt: v.salt, saltNonce: v.saltNonce }; } /** - * Type guard for a Permit2-shaped auth-capture payload. Checks for the - * `permit2Authorization` envelope (with `from`, `spender`, `nonce`, - * `deadline`, `permitted`) plus the required `signature` and `salt` fields. + * Type guard for an EIP-3009-shaped auth-capture collect payload. Rejects + * lifecycle envelopes (`payload.type` set) so `payload.type` remains the + * discriminant the facilitator dispatches on. * * @param value - Candidate payment payload from the wire. - * @returns True if `value` has the Permit2 envelope shape. + * @returns True if `value` has the EIP-3009 collect envelope shape. + */ +export function isEip3009Payload(value: unknown): value is Eip3009Payload { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if (v.type !== undefined) return false; + if ( + typeof v.authorization !== "object" || + v.authorization === null || + typeof v.signature !== "string" + ) { + return false; + } + const saltFields = collectExtras(v); + if (!saltFields) return false; + const hasAnyCharge = + "amount" in v || + "feeBps" in v || + "feeAmount" in v || + "feeReceiver" in v || + "authorizerSignature" in v; + if (hasAnyCharge) { + if (!saltFields.saltNonce) return false; + return readChargeCompletion(v) !== undefined; + } + return true; +} + +/** + * Type guard for a Permit2-shaped auth-capture collect payload. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` has the Permit2 collect envelope shape. */ export function isPermit2Payload(value: unknown): value is Permit2Payload { if (typeof value !== "object" || value === null) return false; const v = value as Record; - if (typeof v.signature !== "string" || typeof v.salt !== "string") return false; + if (v.type !== undefined) return false; + if (typeof v.signature !== "string") return false; if (typeof v.permit2Authorization !== "object" || v.permit2Authorization === null) return false; const a = v.permit2Authorization as Record; - return ( - typeof a.from === "string" && - typeof a.spender === "string" && - typeof a.nonce === "string" && - typeof a.deadline === "string" && - typeof a.permitted === "object" && - a.permitted !== null - ); + if ( + typeof a.from !== "string" || + typeof a.spender !== "string" || + typeof a.nonce !== "string" || + typeof a.deadline !== "string" || + typeof a.permitted !== "object" || + a.permitted === null + ) { + return false; + } + const saltFields = collectExtras(v); + if (!saltFields) return false; + const hasAnyCharge = + "amount" in v || + "feeBps" in v || + "feeAmount" in v || + "feeReceiver" in v || + "authorizerSignature" in v; + if (hasAnyCharge) { + if (!saltFields.saltNonce) return false; + return readChargeCompletion(v) !== undefined; + } + return true; } -// Discriminated union of all auth-capture payload shapes. -export type AuthCapturePayload = Eip3009Payload | Permit2Payload; +/** + * Type guard for a collect (authorize/charge) payload of either envelope. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` is an EIP-3009 or Permit2 collect envelope. + */ +export function isAuthCaptureCollectPayload(value: unknown): value is AuthCaptureCollectPayload { + return isEip3009Payload(value) || isPermit2Payload(value); +} /** - * Type guard for any auth-capture payload. Returns true if `value` matches - * either the EIP-3009 envelope or the Permit2 envelope. + * Type guard for any auth-capture payload: collect or lifecycle. * * @param value - Candidate payment payload from the wire. - * @returns True if `value` is a valid auth-capture envelope of either shape. + * @returns True if `value` is a valid auth-capture envelope. */ export function isAuthCapturePayload(value: unknown): value is AuthCapturePayload { - return isEip3009Payload(value) || isPermit2Payload(value); + if (isLifecyclePayload(value)) { + return isCapturePayload(value) || isVoidPayload(value) || isRefundPayload(value); + } + return isAuthCaptureCollectPayload(value); } /** - * On-chain PaymentInfo struct (canonical Solidity names — DO NOT RENAME). + * Onchain PaymentInfo struct (canonical Solidity names — DO NOT RENAME). * Reconstructed by the facilitator from extra + payload.salt + payer + receiver/asset/amount. */ export interface PaymentInfoStruct { - operator: `0x${string}`; // = extra.captureAuthorizer + operator: `0x${string}`; payer: `0x${string}`; - receiver: `0x${string}`; // = requirements.payTo - token: `0x${string}`; // = requirements.asset - maxAmount: string; // = requirements.amount + receiver: `0x${string}`; + token: `0x${string}`; + maxAmount: string; preApprovalExpiry: number; - authorizationExpiry: number; // = extra.captureDeadline - refundExpiry: number; // = extra.refundDeadline + authorizationExpiry: number; + refundExpiry: number; minFeeBps: number; maxFeeBps: number; - feeReceiver: `0x${string}`; // = extra.feeRecipient - salt: `0x${string}`; // = payload.salt + feeReceiver: `0x${string}`; + salt: `0x${string}`; +} + +export interface PaymentState { + hasCollectedPayment: boolean; + capturableAmount: bigint; + refundableAmount: bigint; } diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts b/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts index b2c9eb9f9a..daa68e6594 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts @@ -1,17 +1,102 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import type { AuthCaptureDeployment } from "./constants"; +import type { + AuthCaptureCollectPayload, + AuthCaptureExtra, + Eip3009Payload, + PaymentInfoStruct, + Permit2Payload, +} from "./types"; + /** - * Parse chainId from CAIP-2 network identifier + * Reconstruct the onchain PaymentInfo struct from the inputs the facilitator + * has after verifying a wire payload. Wire-only inputs: `payer` and `salt` + * (both from the payload). `preApprovalExpiry` is computed by the caller from + * the payload (ERC-3009 `validBefore` or Permit2 `deadline`). The remaining + * fields come from `requirements` (receiver/token/maxAmount) and + * `requirements.extra` (capture/refund deadlines, fee policy, captureAuthorizer). * - * @param network - CAIP-2 network identifier (e.g., 'eip155:84532') - * @returns The chain ID as a number + * @param payer - Address recovered from the wire payload's signature. + * @param preApprovalExpiry - Pre-approval expiry in Unix seconds (from the wire payload). + * @param salt - 32-byte salt from the wire payload (PaymentInfo.salt). + * @param requirements - The payment requirements published by the server. + * @param extra - The validated `AuthCaptureExtra` subset of `requirements.extra`. + * @param maxAmount - Client-signed maximum; defaults to `requirements.amount`. + * @returns A PaymentInfo struct ready to hand to the escrow contract. */ -export function parseChainId(network: string): number { - const parts = network.split(":"); - if (parts.length !== 2 || parts[0] !== "eip155") { - throw new Error(`Invalid network format: ${network}. Expected 'eip155:'`); - } - const chainId = parseInt(parts[1], 10); - if (isNaN(chainId)) { - throw new Error(`Invalid chainId in network: ${network}`); +export function reconstructPaymentInfo( + payer: `0x${string}`, + preApprovalExpiry: number, + salt: `0x${string}`, + requirements: PaymentRequirements, + extra: AuthCaptureExtra, + maxAmount: string = requirements.amount, +): PaymentInfoStruct { + return { + operator: extra.captureAuthorizer, + payer, + receiver: requirements.payTo as `0x${string}`, + token: requirements.asset as `0x${string}`, + maxAmount, + preApprovalExpiry, + authorizationExpiry: extra.captureDeadline, + refundExpiry: extra.refundDeadline, + minFeeBps: extra.minFeeBps, + maxFeeBps: extra.maxFeeBps, + feeReceiver: extra.feeRecipient, + salt, + }; +} + +/** + * Convert a JS-side PaymentInfo struct (string `maxAmount` and `salt`) into + * the bigint-typed form viem expects when encoding the onchain tuple. + * + * @param p - PaymentInfo with string-form numeric fields. + * @returns The same struct with `maxAmount` and `salt` coerced to bigint. + */ +export function paymentInfoToContractTuple(p: PaymentInfoStruct) { + return { ...p, maxAmount: BigInt(p.maxAmount), salt: BigInt(p.salt) }; +} + +/** + * Unpack the per-method inputs the escrow needs at collect settle time. + * + * `collectorData` is the client's signature exactly as it arrived, ERC-6492 wrapper and + * all. Both canonical collectors pass it through `ERC6492SignatureHandler`, which strips + * the wrapper, runs the preparation call via Multicall3, and hands only the inner + * signature to the token or Permit2. Unwrapping here would therefore drop the deployment + * step an undeployed payer wallet depends on. + * + * @param wirePayload - The verified collect payload (EIP-3009 or Permit2). + * @param assetTransferMethod - Which envelope the payload uses. + * @param deployment - Resolved commerce-payments deployment. + * @returns `preApprovalExpiry`, signed `amount`, `tokenCollector`, and `collectorData`. + */ +export function unpackForSettle( + wirePayload: AuthCaptureCollectPayload, + assetTransferMethod: "eip3009" | "permit2", + deployment: AuthCaptureDeployment, +): { + preApprovalExpiry: number; + amount: bigint; + tokenCollector: `0x${string}`; + collectorData: `0x${string}`; +} { + if (assetTransferMethod === "eip3009") { + const p = wirePayload as Eip3009Payload; + return { + preApprovalExpiry: Number(p.authorization.validBefore), + amount: BigInt(p.authorization.value), + tokenCollector: deployment.eip3009Collector, + collectorData: p.signature, + }; } - return chainId; + const p = wirePayload as Permit2Payload; + return { + preApprovalExpiry: Number(p.permit2Authorization.deadline), + amount: BigInt(p.permit2Authorization.permitted.amount), + tokenCollector: deployment.permit2Collector, + collectorData: p.signature, + }; } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts index 4527a970e0..56ee1db72b 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts @@ -5,6 +5,7 @@ import { VerifyResponse, SettleResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { getAddress, parseErc6492Signature, isAddressEqual } from "viem"; import { FacilitatorEvmSigner } from "../../signer"; import type { Erc20ApprovalGasSponsoringSigner } from "../../exact/extensions"; @@ -19,7 +20,10 @@ import { finalHashFromTwoRequestSend, getEvmChainId, truncateErrorMessage } from import { multicall } from "../../multicall"; import * as Errors from "../errors"; import { ErrSettlementPending } from "../../exact/facilitator/errors"; -import { waitAndReturnSettleResponse } from "../../shared/settleReceipt"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; import { readChannelState, toContractChannelConfig, @@ -312,12 +316,151 @@ async function verifySharedDepositState( }; } +/** + * Returns the unique-per-payload key used to key the `PendingSettlementStore` + * for a deposit settle: the payer's authorization signature (ERC-3009 or + * Permit2, whichever the payload carries). Returns `undefined` when no + * signature is available (malformed payload), disabling the + * pending-settlement fast path for that call — the normal broadcast path + * still runs and surfaces the appropriate validation error. Mirrors Go's + * `depositSettlementCacheKey`. + * + * @param payload - Batch deposit payload. + * @param transferMethod - The resolved asset transfer method. + * @returns The pending-settlement store key, or `undefined`. + */ +function depositSettlementCacheKey( + payload: BatchSettlementDepositPayload, + transferMethod: BatchSettlementAssetTransferMethod, +): string | undefined { + if (transferMethod === "eip3009") { + return payload.deposit.authorization.erc3009Authorization?.signature; + } + return payload.deposit.authorization.permit2Authorization?.signature; +} + +/** + * Handles a `PendingSettlementStore` cache hit for {@link settleDeposit}: a + * prior call already broadcast `cachedTx` (its nonce/signature is now + * consumed onchain, so re-broadcasting is not an option) but couldn't + * confirm it before returning `settlement_pending`. Unlike the normal path, + * there is no reliable pre-broadcast channel-state snapshot available here + * (it lived in the earlier, now-returned call), so this reads whatever the + * RPC currently reports on confirmation instead of the optimistic + * balance-add fallback the normal path uses. Mirrors Go's + * `reconcilePendingDeposit`. + * + * @param signer - Facilitator signer used for the receipt wait and channel-state read. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - The deposit payload (channelConfig, amount, authorization, voucher). + * @param requirements - Server payment requirements. + * @param context - Optional facilitator extension context. + * @param cachedTx - The previously broadcast transaction hash. + * @param store - Pending-settlement store to update. + * @param cacheKey - The key this deposit is cached under. + * @returns A {@link SettleResponse} reconciled against the cached transaction. + * + * Known limitation: the cache-miss path's unconfirmedBundleHash check (guarding against a + * non-conforming ERC-20-approval extension signer that bundles a single hash covering only + * approve(), never running deposit()) has no equivalent here, since it needs the + * pre-broadcast channel state this path doesn't have — a receipt success here is trusted at + * face value. Only affects a non-conforming extension signer combined with a confirm-timeout + * on the original request. + */ +async function reconcilePendingDeposit( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context: FacilitatorContext | undefined, + cachedTx: `0x${string}`, + store: PendingSettlementStore, + cacheKey: string, +): Promise { + const { deposit, voucher } = payload; + const config = payload.channelConfig; + const payer = config.payer; + + // Resolve which signer originally broadcast: the erc20-approval-gas-sponsoring + // branch uses the extension signer; every other branch uses the base signer. + const execution = await resolveDepositExecution(signer, payment, payload, requirements, context); + if ("isValid" in execution) { + const reason = execution.invalidReason ?? Errors.ErrInvalidPayloadType; + return { + success: false, + errorReason: reason, + errorMessage: execution.invalidMessage ?? reason, + transaction: "", + network: requirements.network, + payer: execution.payer ?? payer, + }; + } + const receiptSigner = execution.kind === "erc20Approval" ? execution.extensionSigner : signer; + + // Best-effort optimistic fallback for the spec-required extra.channelState, used + // only if the post-confirm read below fails. Not a true pre-broadcast snapshot + // (the deposit may already be reflected here), but the best available base, + // mirroring the optimistic balance-add the main deposit path uses. + let optimisticExtra: Record | undefined; + try { + const preState = await readChannelState(signer, voucher.channelId); + optimisticExtra = { + channelState: { + channelId: voucher.channelId, + balance: (preState.balance + BigInt(deposit.amount)).toString(), + totalClaimed: preState.totalClaimed.toString(), + withdrawRequestedAt: preState.withdrawRequestedAt, + refundNonce: preState.refundNonce.toString(), + }, + }; + } catch { + // No optimistic fallback available; extra stays undefined below if the + // post-confirm read also fails. + } + + return withPendingSettlementStore(store, cacheKey, () => + waitAndReturnSettleResponse(receiptSigner, cachedTx, requirements.network, payer, { + failedStatusReason: Errors.ErrDepositTransactionFailed, + onSuccess: async () => { + let extra = optimisticExtra; + try { + const state = await readChannelState(signer, voucher.channelId); + extra = { + channelState: { + channelId: voucher.channelId, + balance: state.balance.toString(), + totalClaimed: state.totalClaimed.toString(), + withdrawRequestedAt: state.withdrawRequestedAt, + refundNonce: state.refundNonce.toString(), + }, + }; + } catch { + // Keep the optimistic snapshot (if any) when the post-confirm read fails; + // the transaction is still reported as confirmed. + } + return { + success: true, + transaction: cachedTx, + network: requirements.network, + payer, + amount: deposit.amount, + ...(extra ? { extra } : {}), + }; + }, + }), + ); +} + /** * Executes a deposit onchain through the collector for the selected transfer method. * * The deposit is first verified via {@link verifyDeposit}; if invalid the returned * {@link SettleResponse} will have `success: false` with the verification reason. * + * `store` is consulted first (keyed by {@link depositSettlementCacheKey}) to reconcile + * a previously-broadcast-but-unconfirmed deposit transaction from a prior + * `settlement_pending` response, instead of re-broadcasting. + * * @param signer - Facilitator signer used to submit the onchain transaction. * @param payment - Full payment envelope containing optional extensions. * @param payload - The deposit payload (channelConfig, amount, authorization, voucher). @@ -325,6 +468,8 @@ async function verifySharedDepositState( * @param context - Optional facilitator extension context. * @param dataSuffix - Optional hex suffix appended to the deposit transaction. * @param allowedFactories - Allowlisted ERC-6492 factory addresses for counterfactual deposits. + * @param store - Pending-settlement store. Defaults to a fresh in-memory store when + * omitted (no cross-call sharing). * @returns A {@link SettleResponse} with the transaction hash and updated channel state in `extra`. */ export async function settleDeposit( @@ -335,11 +480,39 @@ export async function settleDeposit( context?: FacilitatorContext, dataSuffix?: `0x${string}`, allowedFactories: string[] = [], + store: PendingSettlementStore = new InMemoryPendingSettlementStore(), ): Promise { const { deposit, voucher } = payload; const config = payload.channelConfig; const payer = config.payer; + // Pending-settlement fast path: a prior settle for this exact authorization + // broadcast a transaction but couldn't confirm it in time. Reconcile against + // that transaction instead of re-broadcasting (which would revert on replay + // — the authorization's nonce/signature has already been consumed onchain). + const transferMethod = resolveDepositTransferMethod(payload, requirements); + const cacheKey = depositSettlementCacheKey(payload, transferMethod); + if (cacheKey) { + const cachedTx = await store.get(cacheKey); + if (cachedTx) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + await store.delete(cacheKey); + return reconcilePendingDeposit( + signer, + payment, + payload, + requirements, + context, + cachedTx as `0x${string}`, + store, + cacheKey, + ); + } + } + const verified = await verifyDeposit( signer, payment, @@ -434,87 +607,89 @@ export async function settleDeposit( receiptSigner = signer; } - return await waitAndReturnSettleResponse(receiptSigner, tx, requirements.network, payer, { - failedStatusReason: Errors.ErrDepositTransactionFailed, - onSuccess: async () => { - const optimisticExtra = { - channelState: { - channelId: voucher.channelId, - balance: ( - BigInt(String(verified.extra?.balance ?? "0")) + BigInt(deposit.amount) - ).toString(), - totalClaimed: String(verified.extra?.totalClaimed ?? "0"), - withdrawRequestedAt: Number(verified.extra?.withdrawRequestedAt ?? 0), - refundNonce: String(verified.extra?.refundNonce ?? "0"), - }, - }; - - // Poll until RPC reflects the confirmed deposit so later verify reads see this balance. - const expectedMinBalance = BigInt(optimisticExtra.channelState.balance); - const rpcDeadline = Date.now() + CHANNEL_STATE_POLL_MS; - let balanceConfirmed = false; - // Set only when a read succeeds and shows the deposit missing, as opposed to a read - // that failed outright and leaves the balance unknown. - let balanceReadWithoutConfirmation = false; - try { - let postState = await readChannelState(signer, voucher.channelId); - while (postState.balance < expectedMinBalance && Date.now() < rpcDeadline) { - await new Promise(resolve => setTimeout(resolve, CHANNEL_STATE_POLL_INTERVAL_MS)); - postState = await readChannelState(signer, voucher.channelId); - } - - if (postState.balance >= expectedMinBalance) { - balanceConfirmed = true; - optimisticExtra.channelState = { + return await withPendingSettlementStore(store, cacheKey, () => + waitAndReturnSettleResponse(receiptSigner, tx, requirements.network, payer, { + failedStatusReason: Errors.ErrDepositTransactionFailed, + onSuccess: async () => { + const optimisticExtra = { + channelState: { channelId: voucher.channelId, - balance: postState.balance.toString(), - totalClaimed: postState.totalClaimed.toString(), - withdrawRequestedAt: postState.withdrawRequestedAt, - refundNonce: postState.refundNonce.toString(), - }; - } else { - balanceReadWithoutConfirmation = true; + balance: ( + BigInt(String(verified.extra?.balance ?? "0")) + BigInt(deposit.amount) + ).toString(), + totalClaimed: String(verified.extra?.totalClaimed ?? "0"), + withdrawRequestedAt: Number(verified.extra?.withdrawRequestedAt ?? 0), + refundNonce: String(verified.extra?.refundNonce ?? "0"), + }, + }; + + // Poll until RPC reflects the confirmed deposit so later verify reads see this balance. + const expectedMinBalance = BigInt(optimisticExtra.channelState.balance); + const rpcDeadline = Date.now() + CHANNEL_STATE_POLL_MS; + let balanceConfirmed = false; + // Set only when a read succeeds and shows the deposit missing, as opposed to a read + // that failed outright and leaves the balance unknown. + let balanceReadWithoutConfirmation = false; + try { + let postState = await readChannelState(signer, voucher.channelId); + while (postState.balance < expectedMinBalance && Date.now() < rpcDeadline) { + await new Promise(resolve => setTimeout(resolve, CHANNEL_STATE_POLL_INTERVAL_MS)); + postState = await readChannelState(signer, voucher.channelId); + } + + if (postState.balance >= expectedMinBalance) { + balanceConfirmed = true; + optimisticExtra.channelState = { + channelId: voucher.channelId, + balance: postState.balance.toString(), + totalClaimed: postState.totalClaimed.toString(), + withdrawRequestedAt: postState.withdrawRequestedAt, + refundNonce: postState.refundNonce.toString(), + }; + } else { + balanceReadWithoutConfirmation = true; + } + } catch { + // Keep optimistic channel state when post-deposit reads fail. } - } catch { - // Keep optimistic channel state when post-deposit reads fail. - } - if (unconfirmedBundleHash && !balanceConfirmed) { - // A read showing the deposit missing is terminal; a failed read leaves it - // unconfirmed, so report settlement_pending for the caller to reconcile. - return balanceReadWithoutConfirmation - ? { - success: false, - errorReason: Errors.ErrDepositTransactionFailed, - errorMessage: - "extension signer returned a single transaction hash for the erc20 approval + " + - "deposit bundle, but the resulting channel balance does not reflect the deposit", - transaction: tx, - network: requirements.network, - payer, - } - : { - success: false, - errorReason: ErrSettlementPending, - errorMessage: - "extension signer returned a single transaction hash for the erc20 approval + " + - "deposit bundle and the post-deposit balance read failed, so the deposit could not be confirmed", - transaction: tx, - network: requirements.network, - payer, - }; - } + if (unconfirmedBundleHash && !balanceConfirmed) { + // A read showing the deposit missing is terminal; a failed read leaves it + // unconfirmed, so report settlement_pending for the caller to reconcile. + return balanceReadWithoutConfirmation + ? { + success: false, + errorReason: Errors.ErrDepositTransactionFailed, + errorMessage: + "extension signer returned a single transaction hash for the erc20 approval + " + + "deposit bundle, but the resulting channel balance does not reflect the deposit", + transaction: tx, + network: requirements.network, + payer, + } + : { + success: false, + errorReason: ErrSettlementPending, + errorMessage: + "extension signer returned a single transaction hash for the erc20 approval + " + + "deposit bundle and the post-deposit balance read failed, so the deposit could not be confirmed", + transaction: tx, + network: requirements.network, + payer, + }; + } - return { - success: true, - transaction: tx, - network: requirements.network, - payer, - amount: deposit.amount, - extra: optimisticExtra, - }; - }, - }); + return { + success: true, + transaction: tx, + network: requirements.network, + payer, + amount: deposit.amount, + extra: optimisticExtra, + }; + }, + }), + ); } catch (e) { return { success: false, diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts index 2479a5daf9..832617bbce 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts @@ -6,6 +6,7 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { FacilitatorEvmSigner } from "../../signer"; import { BATCH_SETTLEMENT_SCHEME } from "../constants"; import { @@ -35,6 +36,18 @@ export interface BatchSettlementEvmSchemeConfig { * @default [] */ eip6492AllowedFactories?: string[]; + /** + * Lets a retried deposit settle for the same authorization reconcile + * against an already-broadcast transaction instead of re-broadcasting + * (see {@link PendingSettlementStore}). Only the deposit settle path + * consults this store; claim/settle/refund are single-signature onchain + * calls with no equivalent broadcast-then-reconcile flow. Defaults to a + * fresh in-memory store shared across all deposit settle calls on this + * scheme instance. Inject a shared, network-backed implementation (e.g. + * Redis) for a multi-instance facilitator so a settle retry landing on a + * different replica still reconciles correctly. + */ + pendingSettlementStore?: PendingSettlementStore; } /** @@ -46,7 +59,8 @@ export interface BatchSettlementEvmSchemeConfig { export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { readonly scheme = BATCH_SETTLEMENT_SCHEME; readonly caipFamily = "eip155:*"; - private readonly config: Required; + private readonly config: Required>; + private readonly pendingStore: PendingSettlementStore; /** * Creates a facilitator scheme for verifying and settling batch-settlement payments. @@ -69,6 +83,7 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { this.config = { eip6492AllowedFactories: config?.eip6492AllowedFactories ?? [], }; + this.pendingStore = config?.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); } /** @@ -183,6 +198,7 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { context, dataSuffix, this.config.eip6492AllowedFactories, + this.pendingStore, ); } diff --git a/typescript/packages/mechanisms/evm/src/exact/extensions.ts b/typescript/packages/mechanisms/evm/src/exact/extensions.ts index 5afcb4bd8b..609c2ccc74 100644 --- a/typescript/packages/mechanisms/evm/src/exact/extensions.ts +++ b/typescript/packages/mechanisms/evm/src/exact/extensions.ts @@ -1,4 +1,4 @@ -import type { PaymentPayload } from "@x402/core/types"; +import type { FacilitatorContext, PaymentPayload } from "@x402/core/types"; import type { FacilitatorEvmSigner } from "../signer"; export const EIP2612_GAS_SPONSORING_KEY = "eip2612GasSponsoring" as const; @@ -178,3 +178,32 @@ export function resolveErc20ApprovalExtensionSigner( if (!extension) return undefined; return extension.signerForNetwork?.(network) ?? extension.signer; } + +/** + * Resolves the signer that should wait for a Permit2 settlement receipt. + * + * When the ERC-20-approval-gas-sponsoring extension broadcast the settlement + * transaction, that extension's signer may be a different account/RPC context + * than the base facilitator signer, so the receipt must be awaited through it + * instead. Depends only on `payload.extensions` and `context`, so it can be + * resolved before verify runs (the pending-settlement fast path skips verify + * entirely and reconciles a previously-broadcast transaction directly). + * + * @param signer - The base facilitator signer. + * @param payload - The payment payload (read for its extensions). + * @param context - Optional facilitator context providing extension signers. + * @returns The signer that should be used to await the settlement receipt. + */ +export function resolvePermit2ReceiptWaitSigner( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + context: FacilitatorContext | undefined, +): FacilitatorEvmSigner { + if (!context) return signer; + const erc20Info = extractErc20ApprovalGasSponsoringInfo(payload); + if (!erc20Info) return signer; + const extension = context.getExtension( + ERC20_APPROVAL_GAS_SPONSORING_KEY, + ); + return resolveErc20ApprovalExtensionSigner(extension, payload.accepted.network) ?? signer; +} diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts index 5767ae90b9..5d2d87bb41 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts @@ -5,6 +5,7 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { getAddress, Hex, isAddressEqual, parseErc6492Signature } from "viem"; import { authorizationTypes } from "../../constants"; import { FacilitatorEvmSigner } from "../../signer"; @@ -20,7 +21,10 @@ import { simulateEip3009TransferResult, verifyEip3009TransferEvent, } from "./eip3009-utils"; -import { waitAndReturnSettleResponse } from "../../shared/settleReceipt"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; export interface VerifyEIP3009Options { /** Run onchain simulation. Defaults to true. */ @@ -250,6 +254,69 @@ export async function verifyEIP3009( }; } +/** + * Waits for the broadcast transaction's receipt (and, when logs are present, + * verifies its Transfer event), shared by both the normal broadcast path and + * the pending-settlement reconciliation path in {@link settleEIP3009}. + * Delegates `PendingSettlementStore` bookkeeping to + * {@link withPendingSettlementStore}: only a `settlement_pending` outcome + * (a receipt-wait failure, or a confirmed receipt whose logs couldn't be + * parsed) is recorded; a confirmed receipt with a mismatched Transfer event + * is terminal and clears the entry instead. + * + * @param signer - The facilitator signer for the receipt wait + * @param store - Pending-settlement store keyed by the EIP-3009 signature + * @param pendingKey - The EIP-3009 signature used as the store key + * @param tx - The broadcast transaction hash to await + * @param network - The network the transaction was broadcast to + * @param payer - The payer address + * @param asset - The ERC-20 asset address (checksummed) + * @param auth - The authorization fields used to validate the Transfer event + * @param auth.from - Authorization sender + * @param auth.to - Authorization recipient + * @param auth.value - Authorization value (atomic units) + * @returns Promise resolving to the settlement response + */ +async function awaitEIP3009Settlement( + signer: FacilitatorEvmSigner, + store: PendingSettlementStore, + pendingKey: string, + tx: `0x${string}`, + network: PaymentRequirements["network"], + payer: string, + asset: `0x${string}`, + auth: { from: string; to: string; value: string }, +): Promise { + return withPendingSettlementStore( + store, + pendingKey, + () => + waitAndReturnSettleResponse(signer, tx, network, payer, { + failedStatusReason: Errors.ErrTransactionFailed, + validateReceipt: receipt => { + if ( + receipt.logs != null && + !verifyEip3009TransferEvent(receipt.logs, asset, { + from: getAddress(auth.from), + to: getAddress(auth.to), + value: BigInt(auth.value), + }) + ) { + return { + success: false, + errorReason: Errors.ErrTransferEventMismatch, + transaction: tx, + network, + payer, + }; + } + return undefined; + }, + }), + Errors.ErrTransactionFailed, + ); +} + /** * Settles an EIP-3009 payment by executing transferWithAuthorization. * @@ -259,6 +326,11 @@ export async function verifyEIP3009( * @param eip3009Payload - The EIP-3009 specific payload * @param config - Facilitator configuration * @param context - Optional facilitator context for extension capabilities + * @param store - Pending-settlement store. A prior settle attempt for this exact + * payload that broadcast a transaction whose receipt wait failed + * (settlement_pending) is reconciled against here instead of re-verifying and + * re-broadcasting. Defaults to a fresh in-memory store when omitted (no + * cross-call sharing). * @returns Promise resolving to settlement response */ export async function settleEIP3009( @@ -268,8 +340,36 @@ export async function settleEIP3009( eip3009Payload: ExactEIP3009Payload, config: EIP3009FacilitatorConfig, context?: FacilitatorContext, + store: PendingSettlementStore = new InMemoryPendingSettlementStore(), ): Promise { const payer = eip3009Payload.authorization.from; + const signature = eip3009Payload.signature; + + // Fast path: a prior settle attempt for this exact payload already broadcast + // a transaction whose receipt wait failed (settlement_pending). The resource + // server's single automatic retry resends the identical payload, so check the + // pending-settlement store before re-verifying/re-broadcasting — reconcile + // against the already-broadcast transaction instead of creating a second one. + if (signature) { + const cachedTx = await store.get(signature); + if (cachedTx) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + await store.delete(signature); + return awaitEIP3009Settlement( + signer, + store, + signature, + cachedTx as `0x${string}`, + payload.accepted.network, + payer, + getAddress(requirements.asset), + eip3009Payload.authorization, + ); + } + } // Re-verify before settling const valid = await verifyEIP3009( @@ -294,7 +394,7 @@ export async function settleEIP3009( // Parse ERC-6492 signature if applicable (for optional deployment). // Keep the full result so we can access the inner signature later for // the post-deploy transfer simulation. - const settleErc6492Data = parseErc6492Signature(eip3009Payload.signature!); + const settleErc6492Data = parseErc6492Signature(signature!); const { address: factoryAddress, data: factoryCalldata, @@ -366,7 +466,7 @@ export async function settleEIP3009( // extracted inner signature for the on-chain transferWithAuthorization call. // FiatTokenV2_2's isValidSignature on the deployed contract expects the compact inner signature const settlePayload = - erc6492InnerSig && erc6492InnerSig !== eip3009Payload.signature + erc6492InnerSig && erc6492InnerSig !== signature ? { ...eip3009Payload, signature: erc6492InnerSig } : eip3009Payload; @@ -375,16 +475,27 @@ export async function settleEIP3009( // Receipt status only proves the tx did not revert. // When logs are present, require the expected ERC-20 Transfer event. - const auth = eip3009Payload.authorization; + if (signature) { + return await awaitEIP3009Settlement( + signer, + store, + signature, + tx, + payload.accepted.network, + payer, + asset, + eip3009Payload.authorization, + ); + } return await waitAndReturnSettleResponse(signer, tx, payload.accepted.network, payer, { failedStatusReason: Errors.ErrTransactionFailed, validateReceipt: receipt => { if ( receipt.logs != null && !verifyEip3009TransferEvent(receipt.logs, asset, { - from: getAddress(auth.from), - to: getAddress(auth.to), - value: BigInt(auth.value), + from: getAddress(eip3009Payload.authorization.from), + to: getAddress(eip3009Payload.authorization.to), + value: BigInt(eip3009Payload.authorization.value), }) ) { return { diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index 1396cadc67..6e8c73042f 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -5,11 +5,13 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, ERC20_APPROVAL_GAS_SPONSORING_KEY, resolveErc20ApprovalExtensionSigner, + resolvePermit2ReceiptWaitSigner, type Eip2612GasSponsoringInfo, type Erc20ApprovalGasSponsoringFacilitatorExtension, type Erc20ApprovalGasSponsoringSigner, @@ -40,7 +42,10 @@ import { mapSettleError, type Permit2ProxyConfig, } from "../../shared/permit2"; -import { waitAndReturnSettleResponse } from "../../shared/settleReceipt"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; const exactProxyConfig: Permit2ProxyConfig = { proxyAddress: x402ExactPermit2ProxyAddress, @@ -324,6 +329,11 @@ export async function verifyPermit2( * @param permit2Payload - The Permit2 specific payload * @param context - Optional facilitator context for extension-provided capabilities * @param config - Optional facilitator config (simulateInSettle) + * @param store - Pending-settlement store. A prior settle attempt for this exact + * payload that broadcast a transaction whose receipt wait failed + * (settlement_pending) is reconciled against here instead of re-verifying and + * re-broadcasting. Defaults to a fresh in-memory store when omitted (no + * cross-call sharing). * @returns Promise resolving to settlement response */ export async function settlePermit2( @@ -333,8 +343,38 @@ export async function settlePermit2( permit2Payload: ExactPermit2Payload, context?: FacilitatorContext, config?: Permit2FacilitatorConfig, + store: PendingSettlementStore = new InMemoryPendingSettlementStore(), ): Promise { const payer = permit2Payload.permit2Authorization.from; + const signature = permit2Payload.signature; + + // Fast path: a prior settle attempt for this exact payload already + // broadcast a transaction whose receipt wait failed (settlement_pending). + // Reconcile against it instead of re-verifying/re-broadcasting. + if (signature) { + const cachedTx = await store.get(signature); + if (cachedTx) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + await store.delete(signature); + const receiptWaitSigner = resolvePermit2ReceiptWaitSigner(signer, payload, context); + return withPendingSettlementStore( + store, + signature, + () => + waitAndReturnSettleResponse( + receiptWaitSigner, + cachedTx as `0x${string}`, + payload.accepted.network, + payer, + { failedStatusReason: Errors.ErrTransactionFailed }, + ), + Errors.ErrTransactionFailed, + ); + } + } const valid = await verifyPermit2(signer, payload, requirements, permit2Payload, context, { simulate: config?.simulateInSettle ?? false, @@ -357,13 +397,19 @@ export async function settlePermit2( // Branch: EIP-2612 gas sponsoring (atomic settleWithPermit via contract) const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - return settlePermit2WithEIP2612( - exactProxyConfig, - signer, - payload, - permit2Payload, - eip2612Info, - dataSuffix, + return withPendingSettlementStore( + store, + signature, + () => + settlePermit2WithEIP2612( + exactProxyConfig, + signer, + payload, + permit2Payload, + eip2612Info, + dataSuffix, + ), + Errors.ErrTransactionFailed, ); } @@ -379,19 +425,30 @@ export async function settlePermit2( payload.accepted.network, ); if (extensionSigner) { - return settlePermit2WithERC20Approval( - exactProxyConfig, - extensionSigner, - payload, - permit2Payload, - erc20Info, - dataSuffix, + return withPendingSettlementStore( + store, + signature, + () => + settlePermit2WithERC20Approval( + exactProxyConfig, + extensionSigner, + payload, + permit2Payload, + erc20Info, + dataSuffix, + ), + Errors.ErrTransactionFailed, ); } } // Branch: standard settle (allowance already on-chain) - return settlePermit2Direct(exactProxyConfig, signer, payload, permit2Payload, dataSuffix); + return withPendingSettlementStore( + store, + signature, + () => settlePermit2Direct(exactProxyConfig, signer, payload, permit2Payload, dataSuffix), + Errors.ErrTransactionFailed, + ); } // --------------------------------------------------------------------------- diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index 12a79e635d..8ae3f049c6 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -6,6 +6,7 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { FacilitatorEvmSigner } from "../../signer"; import { ExactEvmPayloadV2, ExactEIP3009Payload, isPermit2Payload } from "../../types"; import { verifyEIP3009, settleEIP3009 } from "./eip3009"; @@ -28,6 +29,16 @@ export interface ExactEvmSchemeConfig { * @default false */ simulateInSettle?: boolean; + /** + * Lets a retried settle for the same payload reconcile against an + * already-broadcast transaction instead of re-verifying and + * re-broadcasting (see {@link PendingSettlementStore}). Defaults to a + * fresh in-memory store shared across all settle calls on this scheme + * instance. Inject a shared, network-backed implementation (e.g. Redis) + * for a multi-instance facilitator so a settle retry landing on a + * different replica still reconciles correctly. + */ + pendingSettlementStore?: PendingSettlementStore; } /** @@ -39,7 +50,8 @@ export interface ExactEvmSchemeConfig { export class ExactEvmScheme implements SchemeNetworkFacilitator { readonly scheme = "exact"; readonly caipFamily = "eip155:*"; - private readonly config: Required; + private readonly config: Required>; + private readonly pendingStore: PendingSettlementStore; /** * Creates a new ExactEvmScheme facilitator instance. @@ -55,6 +67,7 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { eip6492AllowedFactories: config?.eip6492AllowedFactories ?? [], simulateInSettle: config?.simulateInSettle ?? false, }; + this.pendingStore = config?.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); } /** @@ -127,12 +140,26 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { const isPermit2 = isPermit2Payload(rawPayload); if (isPermit2) { - return settlePermit2(this.signer, payload, requirements, rawPayload, context, { - simulateInSettle: this.config.simulateInSettle, - }); + return settlePermit2( + this.signer, + payload, + requirements, + rawPayload, + context, + { simulateInSettle: this.config.simulateInSettle }, + this.pendingStore, + ); } const eip3009Payload: ExactEIP3009Payload = rawPayload; - return settleEIP3009(this.signer, payload, requirements, eip3009Payload, this.config, context); + return settleEIP3009( + this.signer, + payload, + requirements, + eip3009Payload, + this.config, + context, + this.pendingStore, + ); } } diff --git a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts index 1e90a46856..be0df8edca 100644 --- a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts @@ -18,8 +18,8 @@ export class ExactEvmScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod: AssetTransferMethod = "eip3009"; readonly paymentFlows = { - eip3009: { supported: ["authorization"], default: "authorization" }, - permit2: { supported: ["authorization"], default: "authorization" }, + eip3009: { supported: ["authorization", "upfront"], default: "authorization" }, + permit2: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index cd752564e8..66616abf39 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -134,7 +134,12 @@ export { isAuthCaptureExtra, isAuthCapturePayload } from "./auth-capture/types"; // AuthCapture constants export { AUTH_CAPTURE_ESCROW_ADDRESS, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + AUTH_CAPTURE_ESCROW_V1_1_ADDRESS, AUTH_CAPTURE_SCHEME, EIP3009_TOKEN_COLLECTOR_ADDRESS, + EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS, + OPERATOR_REFUND_COLLECTOR_ADDRESS, PERMIT2_TOKEN_COLLECTOR_ADDRESS, + resolveAuthCaptureDeployment, } from "./auth-capture/constants"; diff --git a/typescript/packages/mechanisms/evm/src/shared/settleReceipt.ts b/typescript/packages/mechanisms/evm/src/shared/settleReceipt.ts index 819c7393c1..20110b1631 100644 --- a/typescript/packages/mechanisms/evm/src/shared/settleReceipt.ts +++ b/typescript/packages/mechanisms/evm/src/shared/settleReceipt.ts @@ -1,4 +1,5 @@ import { Network, SettleResponse } from "@x402/core/types"; +import { PendingSettlementStore } from "@x402/core/facilitator"; import { invalidBroadcastHashResponse, isValidTxHash, truncateErrorMessage } from "../utils"; import { ErrInvalidTransactionState, ErrSettlementPending } from "../exact/facilitator/errors"; import { FacilitatorEvmSigner } from "../signer"; @@ -96,6 +97,75 @@ export async function waitAndReturnSettleResponse( } } +/** + * Wraps a settle attempt with `PendingSettlementStore` bookkeeping: only a + * retryable `settlement_pending` failure (carrying a broadcast transaction + * hash) is recorded, so a subsequent settle attempt for the same payload can + * reconcile against it instead of re-broadcasting. Every other outcome — + * success, or any terminal failure (e.g. a reverted receipt) — clears the + * entry instead; a terminal failure still has a `transaction` hash but is + * not safe to reconcile against indefinitely, so it must not be cached until + * TTL expiry. + * + * If persisting the pending entry itself fails, a later retry has no record + * to reconcile against — blindly returning `settlement_pending` would let it + * re-verify/re-broadcast and risk a double-send. That case is downgraded to + * `nonRetryableReason`, preserving the transaction hash for manual + * reconciliation. A failure to clear the entry (success or terminal-failure + * path) is swallowed instead: the settle outcome is already correct and + * must not be masked by a storage hiccup, and a stale entry merely lingers + * until TTL expiry. + * + * Used by mechanisms whose failure reasons need no special-casing beyond + * "did this attempt broadcast a transaction" (Permit2 exact/upto, + * batch-settlement deposit, EIP-3009). + * + * @param store - The pending-settlement store to update + * @param pendingKey - Deterministic key for this payload (e.g. a signature); when + * undefined, the store is left untouched entirely + * @param settle - Thunk that performs the settle attempt (broadcast + receipt wait) + * @param nonRetryableReason - Error reason to report if a `settlement_pending` + * outcome cannot be persisted to `store`. Defaults to `ErrInvalidTransactionState`. + * @returns The settle result from `settle()`, unmodified unless persisting a + * pending entry failed + */ +export async function withPendingSettlementStore( + store: PendingSettlementStore, + pendingKey: string | undefined, + settle: () => Promise, + nonRetryableReason: string = ErrInvalidTransactionState, +): Promise { + const result = await settle(); + if (!pendingKey) { + return result; + } + + const isPending = + !result.success && result.errorReason === ErrSettlementPending && !!result.transaction; + + if (isPending) { + try { + await store.set(pendingKey, result.transaction as string); + } catch (storeError) { + return { + ...result, + errorReason: nonRetryableReason, + errorMessage: `settlement_pending, but failed to persist for retry: ${ + storeError instanceof Error ? storeError.message : String(storeError) + }`, + }; + } + return result; + } + + try { + await store.delete(pendingKey); + } catch { + // Best-effort cleanup; see doc comment above. + } + return result; +} + /** * Builds the non-terminal failure for a broadcast whose effect could not be established, * preserving the hash for the caller to reconcile against. diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts index b98d5b4e8a..026c9cda39 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts @@ -5,11 +5,13 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, ERC20_APPROVAL_GAS_SPONSORING_KEY, resolveErc20ApprovalExtensionSigner, + resolvePermit2ReceiptWaitSigner, type Erc20ApprovalGasSponsoringFacilitatorExtension, type Erc20ApprovalGasSponsoringSigner, } from "../../exact/extensions"; @@ -48,7 +50,10 @@ import { validateEip2612PermitForPayment, type Permit2ProxyConfig, } from "../../shared/permit2"; -import { waitAndReturnSettleResponse } from "../../shared/settleReceipt"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; import type { Eip2612GasSponsoringInfo } from "../../exact/extensions"; const uptoProxyConfig: Permit2ProxyConfig = { @@ -340,7 +345,15 @@ export async function verifyUptoPermit2( * @param requirements - The payment requirements * @param permit2Payload - The upto Permit2 specific payload with witness data * @param context - Optional facilitator context for extension-provided capabilities - * @param config - Optional facilitator configuration (e.g., simulation settings for settle) + * @param store - Pending-settlement store. A prior settle attempt for this exact + * payload that broadcast a transaction whose receipt wait failed + * (settlement_pending) is reconciled against here instead of re-verifying and + * re-broadcasting. Defaults to a fresh in-memory store when omitted (no + * cross-call sharing). + * @param config - Optional facilitator configuration (e.g., simulation settings for settle). + * Ordered after `store` (unlike the exact scheme's `settlePermit2`) since callers that need a + * custom store rarely also need to override simulation, so this is the common call shape that + * avoids a placeholder `undefined` argument. * @returns Promise resolving to a settlement response indicating success or failure */ export async function settleUptoPermit2( @@ -349,11 +362,41 @@ export async function settleUptoPermit2( requirements: PaymentRequirements, permit2Payload: UptoPermit2Payload, context?: FacilitatorContext, + store: PendingSettlementStore = new InMemoryPendingSettlementStore(), config?: UptoPermit2FacilitatorConfig, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const signature = permit2Payload.signature; const settlementAmount = BigInt(requirements.amount); + // Fast path: a prior settle attempt for this exact payload already + // broadcast a transaction whose receipt wait failed (settlement_pending). + // Reconcile against it instead of re-verifying/re-broadcasting. + if (signature) { + const cachedTx = await store.get(signature); + if (cachedTx) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the normal broadcast path, which independently + // rejects it as an on-chain replay (nonce already consumed). + await store.delete(signature); + const receiptWaitSigner = resolvePermit2ReceiptWaitSigner(signer, payload, context); + return withPendingSettlementStore( + store, + signature, + () => + waitAndReturnSettleResponse( + receiptWaitSigner, + cachedTx as `0x${string}`, + payload.accepted.network, + payer, + { failedStatusReason: ErrUptoTransactionFailed, amount: settlementAmount.toString() }, + ), + ErrUptoTransactionFailed, + ); + } + } + // Re-verify the signature before settling. We override `requirements.amount` // with the *authorized maximum* (`permitted.amount`) — NOT the actual // settlement amount — because `verifyUptoPermit2` performs strict equality @@ -415,14 +458,20 @@ export async function settleUptoPermit2( // Branch: EIP-2612 gas sponsoring (atomic settleWithPermit via contract) const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - return settleUptoWithEIP2612( - signer, - payload, - permit2Payload, - eip2612Info, - settlementAmount, - facilitatorAddress, - dataSuffix, + return withPendingSettlementStore( + store, + signature, + () => + settleUptoWithEIP2612( + signer, + payload, + permit2Payload, + eip2612Info, + settlementAmount, + facilitatorAddress, + dataSuffix, + ), + ErrUptoTransactionFailed, ); } @@ -438,26 +487,38 @@ export async function settleUptoPermit2( payload.accepted.network, ); if (extensionSigner) { - return settleUptoWithERC20Approval( - extensionSigner, - payload, - permit2Payload, - erc20Info, - settlementAmount, - facilitatorAddress, - dataSuffix, + return withPendingSettlementStore( + store, + signature, + () => + settleUptoWithERC20Approval( + extensionSigner, + payload, + permit2Payload, + erc20Info, + settlementAmount, + facilitatorAddress, + dataSuffix, + ), + ErrUptoTransactionFailed, ); } } // Branch: standard settle (allowance already on-chain) - return settleUptoDirect( - signer, - payload, - permit2Payload, - settlementAmount, - facilitatorAddress, - dataSuffix, + return withPendingSettlementStore( + store, + signature, + () => + settleUptoDirect( + signer, + payload, + permit2Payload, + settlementAmount, + facilitatorAddress, + dataSuffix, + ), + ErrUptoTransactionFailed, ); } diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts index 9f2a32b8eb..70c68f44b7 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts @@ -6,10 +6,25 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; import { FacilitatorEvmSigner } from "../../signer"; import { UptoPermit2Payload, isUptoPermit2Payload } from "../../types"; import { verifyUptoPermit2, settleUptoPermit2 } from "./permit2"; +/** Optional configuration for {@link UptoEvmScheme}. */ +export interface UptoEvmSchemeConfig { + /** + * Lets a retried settle for the same payload reconcile against an + * already-broadcast transaction instead of re-verifying and + * re-broadcasting (see {@link PendingSettlementStore}). Defaults to a + * fresh in-memory store shared across all settle calls on this scheme + * instance. Inject a shared, network-backed implementation (e.g. Redis) + * for a multi-instance facilitator so a settle retry landing on a + * different replica still reconciles correctly. + */ + pendingSettlementStore?: PendingSettlementStore; +} + /** * EVM facilitator implementation for the Upto payment scheme. * Handles verification and settlement of Permit2-based payments. @@ -17,13 +32,20 @@ import { verifyUptoPermit2, settleUptoPermit2 } from "./permit2"; export class UptoEvmScheme implements SchemeNetworkFacilitator { readonly scheme = "upto"; readonly caipFamily = "eip155:*"; + private readonly pendingStore: PendingSettlementStore; /** * Creates a new UptoEvmScheme facilitator instance. * * @param signer - The EVM signer for facilitator operations + * @param config - Optional configuration */ - constructor(private readonly signer: FacilitatorEvmSigner) {} + constructor( + private readonly signer: FacilitatorEvmSigner, + config?: UptoEvmSchemeConfig, + ) { + this.pendingStore = config?.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); + } /** * Returns extra metadata required by the upto scheme, including the facilitator address. @@ -106,6 +128,7 @@ export class UptoEvmScheme implements SchemeNetworkFacilitator { requirements, rawPayload as UptoPermit2Payload, context, + this.pendingStore, ); } } diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts index 4da25e36a7..2f6ffbe5ba 100644 --- a/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts @@ -163,13 +163,29 @@ describe("AuthCaptureEvmScheme", () => { const b = (await scheme.createPaymentPayload(2, mockRequirements)) .payload as unknown as Eip3009Payload; expect(a.salt).not.toBe(b.salt); + expect(a.saltNonce).toBeUndefined(); + }); + + it("should emit saltNonce and a bound salt when receiverAuthorizer is set", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + receiverAuthorizer: "0x1111111111111111111111111111111111111111" as `0x${string}`, + }, + }); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.saltNonce).toMatch(/^0x[a-fA-F0-9]{64}$/); + expect(payload.salt).toMatch(/^0x[a-fA-F0-9]{64}$/); + expect(payload.salt).not.toBe(payload.saltNonce); }); it("should throw for invalid network format", async () => { const scheme = new AuthCaptureEvmScheme(mockSigner); const badNetworkRequirements = { ...mockRequirements, network: "solana:mainnet" }; await expect(scheme.createPaymentPayload(2, badNetworkRequirements)).rejects.toThrow( - "Invalid network format", + "Unsupported network format: solana:mainnet (expected eip155:CHAIN_ID)", ); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts index b76efb4cbf..adcf091500 100644 --- a/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "vitest"; -import { zeroAddress } from "viem"; -import { computePayerAgnosticPaymentInfoHash, generateSalt } from "../../../src/auth-capture/nonce"; +import { keccak256, zeroAddress } from "viem"; +import { + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + SALT_BINDING_TYPEHASH, +} from "../../../src/auth-capture/constants"; +import { + computePayerAgnosticPaymentInfoHash, + deriveBoundSalt, + generateSalt, + isSaltBindingOn, +} from "../../../src/auth-capture/nonce"; import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; describe("nonce utilities", () => { @@ -25,6 +34,29 @@ describe("nonce utilities", () => { expect(nonce).toMatch(/^0x[a-fA-F0-9]{64}$/); }); + it("should produce fixed hashes for the default and v1.0 escrow domains", () => { + expect(computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo)).toBe( + "0x341988b065a5131b3a82818eb7aba9010135f326af1af7695fce4d2bbebd0b76", + ); + expect(computePayerAgnosticPaymentInfoHash(8453, mockPaymentInfo)).toBe( + "0xa393f8f76a2327a7678488b2d504bda611b7586bb3f334b255a11bb5a75e79ca", + ); + expect( + computePayerAgnosticPaymentInfoHash( + 84532, + mockPaymentInfo, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + ), + ).toBe("0x19de8ffcb747e5caadb3dda7435cf54992e87cdf0c90e5315ffa129dbb22461e"); + expect( + computePayerAgnosticPaymentInfoHash( + 8453, + mockPaymentInfo, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + ), + ).toBe("0x198bbfeaab2f8e36302c662ae41bceaef47a1eb4bf2549cb87aa8daa7f7bb43a"); + }); + it("should produce deterministic results for same inputs", () => { const nonce1 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); const nonce2 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); @@ -94,4 +126,55 @@ describe("nonce utilities", () => { expect(hexPart).toMatch(/^[0-9a-f]+$/); }); }); + + describe("salt binding", () => { + const authorizer = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const policy = "0x0000000000000000000000000000000000000000" as `0x${string}`; + const nonce = + "0x0000000000000000000000000000000000000000000000000000000000000abc" as `0x${string}`; + + it("is off when receiverAuthorizer and policy are absent or zero", () => { + expect(isSaltBindingOn({})).toBe(false); + expect(isSaltBindingOn({ receiverAuthorizer: policy, policy })).toBe(false); + }); + + it("is on when receiverAuthorizer is non-zero", () => { + expect(isSaltBindingOn({ receiverAuthorizer: authorizer })).toBe(true); + }); + + it("derives a deterministic 32-byte salt from the typehash, addresses, and nonce", () => { + const a = deriveBoundSalt(authorizer, policy, nonce); + const b = deriveBoundSalt(authorizer, policy, nonce); + expect(a).toBe(b); + expect(a).toMatch(/^0x[a-fA-F0-9]{64}$/); + expect(a).toBe("0xd0967e09b6c8fccf96277d95a03e98583e8605ab10858b1349aa50ea6d78132c"); + }); + + it("should hash the salt-binding and PaymentInfo type strings to stable values", () => { + expect(SALT_BINDING_TYPEHASH).toBe( + "0x8a2a7e41a0bda000ded071ff38b79401d2603e1826516ff2635b11fe9e30877f", + ); + expect( + keccak256( + new TextEncoder().encode( + "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)", + ), + ), + ).toBe("0xae68ac7ce30c86ece8196b61a7c486d8f0061f575037fbd34e7fe4e2820c6591"); + }); + + it("changes when any bound input changes", () => { + const base = deriveBoundSalt(authorizer, policy, nonce); + expect(deriveBoundSalt("0x2222222222222222222222222222222222222222", policy, nonce)).not.toBe( + base, + ); + expect( + deriveBoundSalt( + authorizer, + policy, + "0x0000000000000000000000000000000000000000000000000000000000000abd", + ), + ).not.toBe(base); + }); + }); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts index 1b74d8e985..b4862610b1 100644 --- a/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts @@ -128,6 +128,19 @@ describe("type guards", () => { expect(isEip3009Payload(rest)).toBe(false); }); + it("accepts a bound payload that also carries saltNonce", () => { + expect( + isEip3009Payload({ + ...validEip3009, + saltNonce: "0x0000000000000000000000000000000000000000000000000000000000000abc", + }), + ).toBe(true); + }); + + it("rejects a lifecycle payload even if authorization is present", () => { + expect(isEip3009Payload({ ...validEip3009, type: "capture" })).toBe(false); + }); + it("rejects a Permit2 payload (no authorization field)", () => { expect(isEip3009Payload(validPermit2)).toBe(false); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/pendingSettlement.test.ts new file mode 100644 index 0000000000..e1445f431c --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/pendingSettlement.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { MockedFunction } from "vitest"; + +vi.mock("../../../src/multicall", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, multicall: vi.fn() }; +}); + +import { multicall } from "../../../src/multicall"; +import { BatchSettlementEvmScheme } from "../../../src/batch-settlement/facilitator/scheme"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import * as Errors from "../../../src/batch-settlement/errors"; +import { ErrSettlementPending } from "../../../src/exact/facilitator/errors"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import type { + ChannelConfig, + AuthorizerSigner, + BatchSettlementDepositPayload, +} from "../../../src/batch-settlement/types"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { privateKeyToAccount } from "viem/accounts"; + +const mockedMulticall = multicall as unknown as MockedFunction; + +const PAYER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as `0x${string}`; +const RECEIVER = "0x9876543210987654321098765432109876543210" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const FACILITATOR_ADDRESS = "0xFAC11174700123456789012345678901234aBCDe" as `0x${string}`; +const NETWORK = "eip155:84532"; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as `0x${string}`; +const MOCK_TX_HASH = ("0x" + "ab".repeat(32)) as `0x${string}`; + +function computeChannelId(config: ChannelConfig): `0x${string}` { + return computeChannelIdForNetwork(config, NETWORK); +} + +function buildAuthorizerSigner(): AuthorizerSigner { + const account = privateKeyToAccount( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ); + return { + address: account.address, + signTypedData: msg => + account.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; +} + +function buildChannelConfig(overrides: Partial = {}): ChannelConfig { + return { + payer: PAYER, + payerAuthorizer: ZERO_ADDR, + receiver: RECEIVER, + receiverAuthorizer: RECEIVER_AUTHORIZER, + token: ASSET, + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", + ...overrides, + }; +} + +function makeRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + amount: "1000", + asset: ASSET, + payTo: RECEIVER, + maxTimeoutSeconds: 3600, + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + assetTransferMethod: "eip3009", + withdrawDelay: 900, + }, + ...overrides, + }; +} + +function buildSigner(overrides: Partial = {}): FacilitatorEvmSigner { + return { + getAddresses: () => [FACILITATOR_ADDRESS], + readContract: vi.fn().mockImplementation(args => { + if (args.functionName === "isValidSignature") return Promise.resolve("0x1626ba7e"); + if (args.functionName === "receivers") return Promise.resolve([2500n, 0n]); + return Promise.resolve(undefined); + }), + verifyTypedData: vi.fn().mockResolvedValue(true), + writeContract: vi.fn().mockResolvedValue(MOCK_TX_HASH), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn().mockResolvedValue("0x6080604052"), + ...overrides, + }; +} + +function envelopeDeposit(payload: BatchSettlementDepositPayload): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload: payload as unknown as Record, + } as unknown as PaymentPayload; +} + +/** + * Builds an ERC-3009 deposit payload keyed by `signature` for pending-settlement + * store tests. + * + * @param channelId - The channel id from the accompanying voucher/channelConfig + * @param signature - The ERC-3009 authorization signature, used as the store key + * @returns A deposit payload envelope + */ +function buildDepositPayload( + channelId: `0x${string}`, + config: ChannelConfig, + signature: string, +): BatchSettlementDepositPayload { + const now = Math.floor(Date.now() / 1000); + return { + type: "deposit", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "1000", signature: "0xcafebabe" }, + deposit: { + amount: "10000", + authorization: { + erc3009Authorization: { + validAfter: String(now - 600), + validBefore: String(now + 3600), + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature, + }, + }, + }, + }; +} + +// verifyDeposit's shared-state multicall: channels, balanceOf, pendingWithdrawals, refundNonce. +const VERIFY_MULTICALL_RESULT = [ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1_000_000n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, +]; + +// readChannelState's post-broadcast/post-reconcile multicall: channels, pendingWithdrawals, refundNonce. +const READ_CHANNEL_STATE_RESULT = [ + { status: "success", result: [10_000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, +]; + +describe("BatchSettlementEvmScheme deposit pending-settlement store integration", () => { + const authorizer = buildAuthorizerSigner(); + let store: PendingSettlementStore; + + beforeEach(() => { + mockedMulticall.mockReset(); + store = new InMemoryPendingSettlementStore(); + }); + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + mockedMulticall + .mockResolvedValueOnce(VERIFY_MULTICALL_RESULT) + .mockResolvedValue(READ_CHANNEL_STATE_RESULT); + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface1"); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(true); + expect(signer.writeContract).toHaveBeenCalledTimes(1); + expect(await store.get("0xfeedface1")).toBeUndefined(); + }); + + it("cache-miss + failed receipt wait: returns settlement_pending and populates the store keyed by the authorization signature", async () => { + mockedMulticall.mockResolvedValue(VERIFY_MULTICALL_RESULT); + const signer = buildSigner({ + waitForTransactionReceipt: vi.fn().mockRejectedValue(new Error("rpc: timeout")), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface2"); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get("0xfeedface2")).toBe(MOCK_TX_HASH); + }); + + it("cache-hit: skips verify/broadcast entirely and reconciles against the cached tx", async () => { + // Only the post-reconcile readChannelState multicall runs on the cache-hit path. + mockedMulticall.mockResolvedValue(READ_CHANNEL_STATE_RESULT); + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface3"); + await store.set("0xfeedface3", MOCK_TX_HASH); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(signer.writeContract).not.toHaveBeenCalled(); + expect(signer.waitForTransactionReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: MOCK_TX_HASH }), + ); + expect(await store.get("0xfeedface3")).toBeUndefined(); + }); + + it("cache-hit: falls back to an optimistic channelState snapshot when the post-confirm read fails", async () => { + // First multicall (pre-confirm optimistic read) succeeds; the second (post-confirm + // read inside onSuccess) fails, so the response must fall back to the optimistic + // snapshot rather than omitting extra.channelState. + mockedMulticall + .mockResolvedValueOnce(READ_CHANNEL_STATE_RESULT) + .mockRejectedValueOnce(new Error("rpc read failed")); + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface7"); + await store.set("0xfeedface7", MOCK_TX_HASH); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + // READ_CHANNEL_STATE_RESULT reports balance 10_000n; optimistic = balance + deposit.amount (10_000). + expect(result.extra).toEqual({ + channelState: { + channelId, + balance: "20000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + }, + }); + }); + + it("cache-hit: still-unconfirmed receipt wait returns settlement_pending again and preserves the store entry", async () => { + const signer = buildSigner({ + waitForTransactionReceipt: vi.fn().mockRejectedValue(new Error("still pending")), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface4"); + await store.set("0xfeedface4", MOCK_TX_HASH); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(signer.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xfeedface4")).toBe(MOCK_TX_HASH); + }); + + it("terminal verify failure (insufficient balance) never touches the store", async () => { + // verifySharedDepositState's balanceOf call reports less than the deposit amount. + mockedMulticall.mockResolvedValue([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer, { + pendingSettlementStore: store, + }); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface5"); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrInsufficientBalance); + expect(signer.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xfeedface5")).toBeUndefined(); + }); + + it("defaults to a fresh in-memory store when none is provided", async () => { + mockedMulticall + .mockResolvedValueOnce(VERIFY_MULTICALL_RESULT) + .mockResolvedValue(READ_CHANNEL_STATE_RESULT); + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp = buildDepositPayload(channelId, config, "0xfeedface6"); + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + + expect(result.success).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts new file mode 100644 index 0000000000..0d9dba610d --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ExactEvmScheme } from "../../../src/exact/facilitator/scheme"; +import { ExactEvmScheme as ClientExactEvmScheme } from "../../../src/exact/client/scheme"; +import type { ClientEvmSigner, FacilitatorEvmSigner } from "../../../src/signer"; +import { PaymentRequirements, PaymentPayload, FacilitatorContext } from "@x402/core/types"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import { x402ExactPermit2ProxyAddress } from "../../../src/constants"; +import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/exact/extensions"; +import * as Errors from "../../../src/exact/facilitator/errors"; + +const MOCK_TX_HASH = ("0x" + "a1".repeat(32)) as `0x${string}`; + +// Wraps a readContract mock so isValidSignature returns the ERC-1271 magic value while +// delegating other calls to `impl` — mirrors exact/facilitator.test.ts's `rcWithSig`. +const SIG_VALID = "0x1626ba7e"; +function rcWithSig(impl: unknown) { + return vi.fn().mockImplementation(async (args: { functionName?: string }) => { + if (args?.functionName === "isValidSignature") return SIG_VALID; + return impl; + }); +} + +const REQUIREMENTS: PaymentRequirements = { + scheme: "exact", + network: "eip155:84532", + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, +}; + +const PERMIT2_REQUIREMENTS: PaymentRequirements = { + ...REQUIREMENTS, + extra: { ...REQUIREMENTS.extra, assetTransferMethod: "permit2" }, +}; + +/** + * Builds a valid Permit2 payload with the given signature, for tests that + * need to control the pending-settlement-store key precisely. + * + * @param signature - The signature to embed in the payload + * @returns A full permit2 PaymentPayload + */ +function buildPermit2Payload(signature: string): PaymentPayload { + return { + x402Version: 2, + payload: { + signature, + permit2Authorization: { + from: "0x1234567890123456789012345678901234567890", + permitted: { + token: PERMIT2_REQUIREMENTS.asset, + amount: PERMIT2_REQUIREMENTS.amount, + }, + spender: x402ExactPermit2ProxyAddress, + nonce: "12345", + deadline: "999999999999", + witness: { + to: PERMIT2_REQUIREMENTS.payTo, + validAfter: "0", + }, + }, + }, + accepted: PERMIT2_REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; +} + +describe("ExactEvmScheme pending-settlement store integration", () => { + let mockFacilitatorSigner: FacilitatorEvmSigner; + let client: ClientExactEvmScheme; + let mockClientSigner: ClientEvmSigner; + let store: PendingSettlementStore; + + beforeEach(() => { + mockClientSigner = { + address: "0x1234567890123456789012345678901234567890", + signTypedData: vi.fn().mockResolvedValue("0xmocksignature"), + readContract: vi.fn().mockResolvedValue(BigInt(0)), + }; + client = new ClientExactEvmScheme(mockClientSigner); + + mockFacilitatorSigner = { + getAddresses: vi.fn().mockReturnValue(["0x742D35CC6634c0532925A3b844BC9E7595F0BEb0"]), + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return "0x1626ba7e"; + return 0n; + }), + verifyTypedData: vi.fn().mockResolvedValue(true), + writeContract: vi.fn().mockResolvedValue(MOCK_TX_HASH), + sendTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success", logs: null }), + getCode: vi.fn().mockResolvedValue("0x6080604052"), + }; + store = new InMemoryPendingSettlementStore(); + }); + + describe("EIP-3009", () => { + it("cache-miss + broadcast success: deletes/leaves no pending entry", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(true); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("cache-miss + broadcast-then-wait-fails: returns settlement_pending and populates the store keyed by the signature", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("rpc: timeout waiting for receipt")); + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("cache-hit: skips verify/broadcast entirely and reconciles against the cached tx", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + await store.set(signature, MOCK_TX_HASH); + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + // Fast path never re-broadcasts. + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(mockFacilitatorSigner.waitForTransactionReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: MOCK_TX_HASH }), + ); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("cache-hit: still unconfirmed on retry returns settlement_pending again without re-broadcasting", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + await store.set(signature, MOCK_TX_HASH); + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("still pending")); + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("verify-only failure (recipient mismatch) is terminal and never touches the store", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + const badRequirements = { + ...REQUIREMENTS, + payTo: "0x0000000000000000000000000000000000000000", + }; + + const result = await facilitator.settle(fullPayload, badRequirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrRecipientMismatch); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("invalid broadcast hash is terminal and never populates the store", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + const signature = (fullPayload.payload as { signature: string }).signature; + mockFacilitatorSigner.writeContract = vi.fn().mockResolvedValue("0xnothash"); + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionFailed); + expect(await store.get(signature)).toBeUndefined(); + }); + }); + + describe("Permit2", () => { + beforeEach(() => { + // settle's re-verify has simulate=false by default, so no simulation + // readContract is needed. The default getCode mock reports bytecode for + // every address (including the payer), so signature verification routes + // through the strict EIP-1271 path (isValidSignature) rather than real + // ECDSA recovery against the mock's non-cryptographic signature string. + mockFacilitatorSigner.readContract = rcWithSig(undefined); + }); + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload("0xmocksignature"); + + const result = await facilitator.settle(payload, PERMIT2_REQUIREMENTS); + + expect(result.success).toBe(true); + expect(await store.get("0xmocksignature")).toBeUndefined(); + }); + + it("cache-miss + broadcast-then-wait-fails: returns settlement_pending and populates the store", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("rpc timeout")); + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload("0xmocksignature"); + + const result = await facilitator.settle(payload, PERMIT2_REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get("0xmocksignature")).toBe(MOCK_TX_HASH); + }); + + it("cache-hit: skips verify/broadcast and reconciles against the cached tx", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload("0xmocksignature"); + await store.set("0xmocksignature", MOCK_TX_HASH); + + const result = await facilitator.settle(payload, PERMIT2_REQUIREMENTS); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksignature")).toBeUndefined(); + }); + + it("cache-hit: still unconfirmed returns settlement_pending again", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("still pending")); + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload("0xmocksignature"); + await store.set("0xmocksignature", MOCK_TX_HASH); + + const result = await facilitator.settle(payload, PERMIT2_REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksignature")).toBe(MOCK_TX_HASH); + }); + + it("cache-hit with ERC-20-approval extension: reconciles via the extension signer, not the base signer", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload: PaymentPayload = { + ...buildPermit2Payload("0xmocksignature"), + extensions: { + erc20ApprovalGasSponsoring: { + info: { + from: "0x1234567890123456789012345678901234567890", + asset: PERMIT2_REQUIREMENTS.asset, + spender: "0x000000000022D473030F116dDEE9F6B43aC78BA3", + amount: PERMIT2_REQUIREMENTS.amount, + signedTransaction: "0x" + "aa".repeat(100), + version: "1", + }, + schema: {}, + }, + }, + }; + await store.set("0xmocksignature", MOCK_TX_HASH); + + const extensionWaitForReceipt = vi.fn().mockResolvedValue({ status: "success", logs: null }); + const baseSignerWaitForReceipt = vi.fn(); + mockFacilitatorSigner.waitForTransactionReceipt = baseSignerWaitForReceipt; + const mockContext = { + getExtension: vi.fn().mockImplementation((key: string) => { + if (key === ERC20_APPROVAL_GAS_SPONSORING_KEY) { + return { + key: ERC20_APPROVAL_GAS_SPONSORING_KEY, + signer: { + ...mockFacilitatorSigner, + waitForTransactionReceipt: extensionWaitForReceipt, + }, + }; + } + return undefined; + }), + }; + + const result = await facilitator.settle( + payload, + PERMIT2_REQUIREMENTS, + mockContext as unknown as FacilitatorContext, + ); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(extensionWaitForReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: MOCK_TX_HASH }), + ); + expect(baseSignerWaitForReceipt).not.toHaveBeenCalled(); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("verify-only failure (spender mismatch) is terminal and never touches the store", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload("0xmocksignature"); + ( + payload.payload as { permit2Authorization: { spender: string } } + ).permit2Authorization.spender = "0x0000000000000000000000000000000000000000"; + + const result = await facilitator.settle(payload, PERMIT2_REQUIREMENTS); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrPermit2InvalidSpender); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksignature")).toBeUndefined(); + }); + }); + + describe("default store", () => { + it("uses a fresh in-memory store when none is provided, so settle still succeeds", async () => { + const facilitator = new ExactEvmScheme(mockFacilitatorSigner); + const paymentPayload = await client.createPaymentPayload(2, REQUIREMENTS); + const fullPayload: PaymentPayload = { + ...paymentPayload, + accepted: REQUIREMENTS, + resource: { url: "", description: "", mimeType: "" }, + }; + + const result = await facilitator.settle(fullPayload, REQUIREMENTS); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/server.test.ts b/typescript/packages/mechanisms/evm/test/unit/server.test.ts index 68c968fbca..7692eb5ce9 100644 --- a/typescript/packages/mechanisms/evm/test/unit/server.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/server.test.ts @@ -204,6 +204,16 @@ describe("ExactEvmScheme (Server)", () => { }); }); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + expect(server.defaultAssetTransferMethod).toBe("eip3009"); + expect(server.paymentFlows).toEqual({ + eip3009: { supported: ["authorization", "upfront"], default: "authorization" }, + permit2: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + describe("enhancePaymentRequirements", () => { it("should return payment requirements unchanged", async () => { const requirements = { diff --git a/typescript/packages/mechanisms/evm/test/unit/shared/settleReceipt.test.ts b/typescript/packages/mechanisms/evm/test/unit/shared/settleReceipt.test.ts index 4dc1941d02..689282be40 100644 --- a/typescript/packages/mechanisms/evm/test/unit/shared/settleReceipt.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/shared/settleReceipt.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { waitAndReturnSettleResponse } from "../../../src/shared/settleReceipt"; +import type { PendingSettlementStore } from "@x402/core/facilitator"; +import type { SettleResponse } from "@x402/core/types"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../../src/shared/settleReceipt"; import { ErrSettlementPending } from "../../../src/exact/facilitator/errors"; // waitAndReturnSettleResponse is the single place every EVM scheme (exact, upto, batch) @@ -113,3 +118,118 @@ describe("waitAndReturnSettleResponse terminal/pending boundary", () => { expect(out.amount).toBe("100"); }); }); + +const KEY = "pending-key"; +const NON_RETRYABLE = "non_retryable_fallback"; + +/** + * Minimal in-memory PendingSettlementStore whose set/delete can be forced to + * reject, to exercise withPendingSettlementStore's storage-failure handling. + */ +class FakeStore implements PendingSettlementStore { + entries = new Map(); + setError?: Error; + deleteError?: Error; + + async get(key: string): Promise { + return this.entries.get(key); + } + + async set(key: string, tx: string): Promise { + if (this.setError) throw this.setError; + this.entries.set(key, tx); + } + + async delete(key: string): Promise { + if (this.deleteError) throw this.deleteError; + this.entries.delete(key); + } +} + +const pendingResult: SettleResponse = { + success: false, + errorReason: ErrSettlementPending, + transaction: TX, + network: NETWORK, + payer: undefined, +}; + +const revertedResult: SettleResponse = { + success: false, + errorReason: FAILED, + transaction: TX, + network: NETWORK, + payer: undefined, +}; + +const successResult: SettleResponse = { + success: true, + transaction: TX, + network: NETWORK, + payer: undefined, +}; + +describe("withPendingSettlementStore", () => { + it("records a settlement_pending outcome for reconciliation", async () => { + const store = new FakeStore(); + const out = await withPendingSettlementStore(store, KEY, async () => pendingResult); + expect(out).toEqual(pendingResult); + expect(store.entries.get(KEY)).toBe(TX); + }); + + it("clears the entry on success", async () => { + const store = new FakeStore(); + store.entries.set(KEY, TX); + const out = await withPendingSettlementStore(store, KEY, async () => successResult); + expect(out).toEqual(successResult); + expect(store.entries.has(KEY)).toBe(false); + }); + + it("clears (never sets) a terminal failure carrying a transaction hash", async () => { + // Regression test: a reverted receipt also has a transaction hash, so a + // naive "does this have a transaction?" check would cache it — leaving a + // stale false-pending entry until TTL expiry (only settlement_pending is + // safe to reconcile against). + const store = new FakeStore(); + store.entries.set(KEY, TX); + const out = await withPendingSettlementStore(store, KEY, async () => revertedResult); + expect(out).toEqual(revertedResult); + expect(store.entries.has(KEY)).toBe(false); + }); + + it("downgrades to non-retryable when persisting the pending entry fails", async () => { + const store = new FakeStore(); + store.setError = new Error("redis unavailable"); + const out = await withPendingSettlementStore( + store, + KEY, + async () => pendingResult, + NON_RETRYABLE, + ); + expect(out.success).toBe(false); + expect(out.errorReason).toBe(NON_RETRYABLE); + expect(out.transaction).toBe(TX); + expect(out.errorMessage).toContain("redis unavailable"); + }); + + it("still returns a successful result when clearing the entry fails", async () => { + const store = new FakeStore(); + store.deleteError = new Error("redis unavailable"); + const out = await withPendingSettlementStore(store, KEY, async () => successResult); + expect(out).toEqual(successResult); + }); + + it("still returns a terminal failure when clearing the entry fails", async () => { + const store = new FakeStore(); + store.deleteError = new Error("redis unavailable"); + const out = await withPendingSettlementStore(store, KEY, async () => revertedResult); + expect(out).toEqual(revertedResult); + }); + + it("leaves the store untouched when there is no pending key", async () => { + const store = new FakeStore(); + const out = await withPendingSettlementStore(store, undefined, async () => pendingResult); + expect(out).toEqual(pendingResult); + expect(store.entries.size).toBe(0); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts new file mode 100644 index 0000000000..747d4e7e71 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UptoEvmScheme } from "../../../src/upto/facilitator/scheme"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import { PaymentPayload, PaymentRequirements, FacilitatorContext } from "@x402/core/types"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import { x402UptoPermit2ProxyAddress } from "../../../src/constants"; +import { ErrSettlementPending } from "../../../src/exact/facilitator/errors"; +import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/exact/extensions"; +import type { UptoPermit2Payload } from "../../../src/types"; + +vi.mock("viem", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + parseTransaction: vi.fn(), + recoverTransactionAddress: vi.fn(), + }; +}); + +const FACILITATOR_ADDRESS = "0xFAC11174700123456789012345678901234aBCDe" as `0x${string}`; +const MOCK_TX_HASH = ("0x" + "12".repeat(32)) as `0x${string}`; + +const now = () => Math.floor(Date.now() / 1000); + +/** + * Wraps a readContract mock so isValidSignature returns the ERC-1271 magic + * value while delegating other calls to `impl` — mirrors + * upto/facilitator.test.ts's `rcWithSig`. + * + * @param impl - Value returned for any call other than isValidSignature + * @returns A mocked readContract implementation + */ +function rcWithSig(impl: unknown) { + return vi.fn().mockImplementation(async (args: { functionName?: string }) => { + if (args?.functionName === "isValidSignature") return "0x1626ba7e"; + return impl; + }); +} + +function makePermit2Payload(overrides?: Partial): UptoPermit2Payload { + const base: UptoPermit2Payload = { + signature: "0xmocksig" as `0x${string}`, + permit2Authorization: { + from: "0x1234567890123456789012345678901234567890", + permitted: { + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "1000000", + }, + spender: x402UptoPermit2ProxyAddress, + nonce: "12345", + deadline: (now() + 3600).toString(), + witness: { + to: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + facilitator: FACILITATOR_ADDRESS, + validAfter: (now() - 600).toString(), + }, + }, + }; + return { ...base, ...overrides }; +} + +function makePayload(permit2?: UptoPermit2Payload): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "upto", network: "eip155:8453" }, + payload: permit2 ?? makePermit2Payload(), + } as PaymentPayload; +} + +function makeRequirements(overrides?: Partial): PaymentRequirements { + return { + scheme: "upto", + network: "eip155:8453", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2", facilitatorAddress: FACILITATOR_ADDRESS }, + ...overrides, + }; +} + +describe("UptoEvmScheme pending-settlement store integration", () => { + let mockSigner: FacilitatorEvmSigner; + let store: PendingSettlementStore; + + beforeEach(() => { + mockSigner = { + getAddresses: () => [FACILITATOR_ADDRESS], + readContract: rcWithSig(BigInt("999999999999999999")), + verifyTypedData: vi.fn().mockResolvedValue(true), + writeContract: vi.fn().mockResolvedValue(MOCK_TX_HASH), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn().mockResolvedValue("0x6080604052"), + }; + store = new InMemoryPendingSettlementStore(); + }); + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const payload = makePayload(); + + const result = await scheme.settle(payload, makeRequirements()); + + expect(result.success).toBe(true); + expect(await store.get("0xmocksig")).toBeUndefined(); + }); + + it("cache-miss + broadcast-then-wait-fails: returns settlement_pending and populates the store", async () => { + mockSigner.waitForTransactionReceipt = vi.fn().mockRejectedValue(new Error("rpc timeout")); + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const payload = makePayload(); + + const result = await scheme.settle(payload, makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get("0xmocksig")).toBe(MOCK_TX_HASH); + }); + + it("cache-hit: skips verify/broadcast and reconciles against the cached tx", async () => { + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const payload = makePayload(); + await store.set("0xmocksig", MOCK_TX_HASH); + + const result = await scheme.settle(payload, makeRequirements()); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksig")).toBeUndefined(); + }); + + it("cache-hit: still unconfirmed returns settlement_pending again without re-broadcasting", async () => { + mockSigner.waitForTransactionReceipt = vi.fn().mockRejectedValue(new Error("still pending")); + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const payload = makePayload(); + await store.set("0xmocksig", MOCK_TX_HASH); + + const result = await scheme.settle(payload, makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksig")).toBe(MOCK_TX_HASH); + }); + + it("cache-hit with ERC-20-approval extension: reconciles via the extension signer, not the base signer", async () => { + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const payload: PaymentPayload = { + ...makePayload(), + extensions: { + erc20ApprovalGasSponsoring: { + info: { + from: "0x1234567890123456789012345678901234567890", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + spender: "0x000000000022D473030F116dDEE9F6B43aC78BA3", + amount: "1000000", + signedTransaction: "0x" + "aa".repeat(100), + version: "1", + }, + schema: {}, + }, + }, + }; + await store.set("0xmocksig", MOCK_TX_HASH); + + const extensionWaitForReceipt = vi.fn().mockResolvedValue({ status: "success" }); + const baseSignerWaitForReceipt = vi.fn(); + mockSigner.waitForTransactionReceipt = baseSignerWaitForReceipt; + const mockContext = { + getExtension: vi.fn().mockImplementation((key: string) => { + if (key === ERC20_APPROVAL_GAS_SPONSORING_KEY) { + return { + key: ERC20_APPROVAL_GAS_SPONSORING_KEY, + signer: { + ...mockSigner, + waitForTransactionReceipt: extensionWaitForReceipt, + }, + }; + } + return undefined; + }), + }; + + const result = await scheme.settle( + payload, + makeRequirements(), + mockContext as unknown as FacilitatorContext, + ); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(extensionWaitForReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: MOCK_TX_HASH }), + ); + expect(baseSignerWaitForReceipt).not.toHaveBeenCalled(); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("verify-only failure (spender mismatch) is terminal and never touches the store", async () => { + const scheme = new UptoEvmScheme(mockSigner, { pendingSettlementStore: store }); + const p2 = makePermit2Payload(); + p2.permit2Authorization.spender = "0x0000000000000000000000000000000000000001"; + const payload = makePayload(p2); + + const result = await scheme.settle(payload, makeRequirements()); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_permit2_spender"); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get("0xmocksig")).toBeUndefined(); + }); + + it("uses a fresh in-memory store by default when none is provided", async () => { + const scheme = new UptoEvmScheme(mockSigner); + const result = await scheme.settle(makePayload(), makeRequirements()); + expect(result.success).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/hedera/CHANGELOG.md b/typescript/packages/mechanisms/hedera/CHANGELOG.md index cf9665b64e..309d072f6b 100644 --- a/typescript/packages/mechanisms/hedera/CHANGELOG.md +++ b/typescript/packages/mechanisms/hedera/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on Hedera `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/hedera/package.json b/typescript/packages/mechanisms/hedera/package.json index c2549e03fc..ff4c869644 100644 --- a/typescript/packages/mechanisms/hedera/package.json +++ b/typescript/packages/mechanisms/hedera/package.json @@ -1,6 +1,6 @@ { "name": "@x402/hedera", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/hedera/src/exact/server/scheme.ts b/typescript/packages/mechanisms/hedera/src/exact/server/scheme.ts index 477029015e..61ad2a95e4 100644 --- a/typescript/packages/mechanisms/hedera/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/hedera/src/exact/server/scheme.ts @@ -29,7 +29,7 @@ export class ExactHederaScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/hedera/test/unit/server.test.ts b/typescript/packages/mechanisms/hedera/test/unit/server.test.ts index 3b243c578a..ad5b835096 100644 --- a/typescript/packages/mechanisms/hedera/test/unit/server.test.ts +++ b/typescript/packages/mechanisms/hedera/test/unit/server.test.ts @@ -3,6 +3,16 @@ import type { PaymentRequirements } from "@x402/core/types"; import { ExactHederaScheme } from "../../src/exact/server/scheme"; describe("ExactHedera server scheme", () => { + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const scheme = new ExactHederaScheme(); + expect(scheme.defaultAssetTransferMethod).toBe("default"); + expect(scheme.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + it("passes through explicit AssetAmount", async () => { const scheme = new ExactHederaScheme(); const parsed = await scheme.parsePrice( diff --git a/typescript/packages/mechanisms/keeta/CHANGELOG.md b/typescript/packages/mechanisms/keeta/CHANGELOG.md index 2aa941e9ac..a05b65670a 100644 --- a/typescript/packages/mechanisms/keeta/CHANGELOG.md +++ b/typescript/packages/mechanisms/keeta/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/keeta +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on Keeta `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/keeta/package.json b/typescript/packages/mechanisms/keeta/package.json index 24fd7ab34d..bb550dd9f7 100644 --- a/typescript/packages/mechanisms/keeta/package.json +++ b/typescript/packages/mechanisms/keeta/package.json @@ -1,6 +1,6 @@ { "name": "@x402/keeta", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/keeta/src/exact/server/scheme.ts b/typescript/packages/mechanisms/keeta/src/exact/server/scheme.ts index cc1c52395a..453b45cf85 100644 --- a/typescript/packages/mechanisms/keeta/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/keeta/src/exact/server/scheme.ts @@ -18,7 +18,7 @@ export class ExactKeetaScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/keeta/test/unit/server.test.ts b/typescript/packages/mechanisms/keeta/test/unit/server.test.ts index d54263eec6..6b00a9fb5a 100644 --- a/typescript/packages/mechanisms/keeta/test/unit/server.test.ts +++ b/typescript/packages/mechanisms/keeta/test/unit/server.test.ts @@ -16,6 +16,15 @@ describe("ExactKeetaServer", () => { vi.clearAllMocks(); }); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + it("has scheme set to exact", () => { expect(server.scheme).toBe("exact"); }); diff --git a/typescript/packages/mechanisms/near/CHANGELOG.md b/typescript/packages/mechanisms/near/CHANGELOG.md index eb7e50aa37..405b4ae814 100644 --- a/typescript/packages/mechanisms/near/CHANGELOG.md +++ b/typescript/packages/mechanisms/near/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/near +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on NEAR `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/near/package.json b/typescript/packages/mechanisms/near/package.json index 6370833708..3a8137ca4b 100644 --- a/typescript/packages/mechanisms/near/package.json +++ b/typescript/packages/mechanisms/near/package.json @@ -1,6 +1,6 @@ { "name": "@x402/near", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/near/src/exact/server/scheme.ts b/typescript/packages/mechanisms/near/src/exact/server/scheme.ts index 70928aee20..830d85c0db 100644 --- a/typescript/packages/mechanisms/near/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/near/src/exact/server/scheme.ts @@ -29,7 +29,7 @@ export class ExactNearScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private readonly moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/near/test/unit/near.server.test.ts b/typescript/packages/mechanisms/near/test/unit/near.server.test.ts index 4439f0b18a..33b5c63736 100644 --- a/typescript/packages/mechanisms/near/test/unit/near.server.test.ts +++ b/typescript/packages/mechanisms/near/test/unit/near.server.test.ts @@ -4,6 +4,16 @@ import { getDefaultAsset, NEAR_MAINNET_CAIP2, NEAR_TESTNET_CAIP2 } from "../../s import { ExactNearScheme } from "../../src/exact/server/scheme"; describe("near server scheme", () => { + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const scheme = new ExactNearScheme(); + expect(scheme.defaultAssetTransferMethod).toBe("default"); + expect(scheme.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + it("parses dollar money into atomic units with the official Circle testnet USDC asset", async () => { const scheme = new ExactNearScheme(); const parsed = await scheme.parsePrice("$1.00", NEAR_TESTNET_CAIP2); diff --git a/typescript/packages/mechanisms/stellar/CHANGELOG.md b/typescript/packages/mechanisms/stellar/CHANGELOG.md index f761a54229..43c9745620 100644 --- a/typescript/packages/mechanisms/stellar/CHANGELOG.md +++ b/typescript/packages/mechanisms/stellar/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/stellar Changelog +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on Stellar `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/stellar/package.json b/typescript/packages/mechanisms/stellar/package.json index 0c2e70a2be..c88da319c9 100644 --- a/typescript/packages/mechanisms/stellar/package.json +++ b/typescript/packages/mechanisms/stellar/package.json @@ -1,6 +1,6 @@ { "name": "@x402/stellar", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/stellar/src/exact/server/scheme.ts b/typescript/packages/mechanisms/stellar/src/exact/server/scheme.ts index 2b17dd9eba..b7bf2adcfb 100644 --- a/typescript/packages/mechanisms/stellar/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/stellar/src/exact/server/scheme.ts @@ -17,7 +17,7 @@ export class ExactStellarScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/stellar/test/integrations/exact-stellar.test.ts b/typescript/packages/mechanisms/stellar/test/integrations/exact-stellar.test.ts index 7770c79c93..55d3aed015 100644 --- a/typescript/packages/mechanisms/stellar/test/integrations/exact-stellar.test.ts +++ b/typescript/packages/mechanisms/stellar/test/integrations/exact-stellar.test.ts @@ -191,7 +191,11 @@ describe.skipIf(missingEnvVars)("Stellar Integration Tests", () => { beforeEach(async () => { const stellarClient = new ExactStellarClient(clientSigner); - client = new x402Client().register(STELLAR_TESTNET_CAIP2, stellarClient); + // Default spend controls reject the XLM test asset before the payment reaches the + // Stellar scheme; this test covers the scheme and facilitator, not spend controls. + client = new x402Client() + .setSpendControls(false) + .register(STELLAR_TESTNET_CAIP2, stellarClient); const stellarFacilitator = new ExactStellarFacilitator([facilitatorSigner]); const facilitator = new x402Facilitator().register(STELLAR_TESTNET_CAIP2, stellarFacilitator); @@ -290,7 +294,10 @@ describe.skipIf(missingEnvVars)("Stellar Integration Tests", () => { const facilitatorClient = new StellarFacilitatorClient(facilitator); const stellarClient = new ExactStellarClient(clientSigner); - const paymentClient = new x402Client().register(STELLAR_TESTNET_CAIP2, stellarClient); + // Default spend controls reject the XLM test asset; see the exact-payment suite above. + const paymentClient = new x402Client() + .setSpendControls(false) + .register(STELLAR_TESTNET_CAIP2, stellarClient); client = new x402HTTPClient(paymentClient) as x402HTTPClient; // Create resource server and register schemes (composition pattern) diff --git a/typescript/packages/mechanisms/stellar/test/unit/server.test.ts b/typescript/packages/mechanisms/stellar/test/unit/server.test.ts index 2094531283..79af6ea930 100644 --- a/typescript/packages/mechanisms/stellar/test/unit/server.test.ts +++ b/typescript/packages/mechanisms/stellar/test/unit/server.test.ts @@ -10,6 +10,15 @@ import { ExactStellarScheme } from "../../src/exact/server/scheme"; describe("ExactStellarScheme", () => { const server = new ExactStellarScheme(); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + describe("parsePrice", () => { describe("Stellar Pubnet network", () => { const network = STELLAR_PUBNET_CAIP2; diff --git a/typescript/packages/mechanisms/svm/CHANGELOG.md b/typescript/packages/mechanisms/svm/CHANGELOG.md index 8933274d75..96766b1c43 100644 --- a/typescript/packages/mechanisms/svm/CHANGELOG.md +++ b/typescript/packages/mechanisms/svm/CHANGELOG.md @@ -1,5 +1,18 @@ # @x402/svm Changelog +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on SVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- [01b0a68](https://github.com/x402-foundation/x402/commit/01b0a68): Verify SVM exact payments without a fee-payer signing round trip: required signatures are checked locally, `simulateTransaction` always runs with `sigVerify` off, and `sendTransaction` skips preflight. Settle checks the duplicate cache before verification, decodes the transaction once (including address lookup tables), and smart-wallet settle fetches a single pre-balance. Confirmation polling starts at 250ms. ([#3263](https://github.com/x402-foundation/x402/pull/3263)) - Thanks [@phdargen](https://github.com/phdargen)! +- [acaa904](https://github.com/x402-foundation/x402/commit/acaa904): Route SVM `upto` facilitator RPC through `toFacilitatorSvmSigner` instead of scheme config: claim settlement simulates before `skipPreflight` send, deposit composite sim uses `replaceRecentBlockhash` without an extra blockhash fetch, and claim overlaps channel read with blockhash prefetch. `UptoSvmFacilitatorConfig.rpc` / `rpcUrl` are removed — pass a paced RPC client or `{ defaultRpcUrl }` to the signer factory (#3183). ([#3274](https://github.com/x402-foundation/x402/pull/3274)) - Thanks [@phdargen](https://github.com/phdargen)! + +### Patch Changes + +- [ec0f71e](https://github.com/x402-foundation/x402/commit/ec0f71e): Reject invalid smart-wallet compute-unit and priority-fee limits when smart-wallet verification is enabled or its exported verification helpers are called, preventing non-finite or fractional configuration values from silently disabling fee protections or causing misleading payment failures. Export `assertSmartWalletLimits` and `SmartWalletLimits` for pre-validation. Dormant limits remain ignored while smart-wallet verification is disabled. ([#3122](https://github.com/x402-foundation/x402/pull/3122)) - Thanks [@notorious-d-e-v](https://github.com/notorious-d-e-v)! + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/svm/package.json b/typescript/packages/mechanisms/svm/package.json index 62aa139d6a..84a1de6610 100644 --- a/typescript/packages/mechanisms/svm/package.json +++ b/typescript/packages/mechanisms/svm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/svm", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", @@ -47,6 +47,7 @@ }, "dependencies": { "@x402/core": "workspace:~", + "@noble/hashes": "^1.8.0", "@solana-program/compute-budget": "^0.11.0", "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1" diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/errors.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/errors.ts new file mode 100644 index 0000000000..d08180aab4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/errors.ts @@ -0,0 +1,100 @@ +/** + * Named error reason constants for the exact SVM facilitator. + * + * These strings must be character-for-character identical to the Go + * constants in go/mechanisms/svm/exact/facilitator/errors.go to maintain + * cross-SDK parity. + */ + +// Verify errors +export const ErrUnsupportedScheme = "invalid_exact_svm_unsupported_scheme"; +export const ErrNetworkMismatch = "invalid_exact_svm_network_mismatch"; +export const ErrMissingFeePayer = "invalid_exact_svm_payload_missing_fee_payer"; +export const ErrFeePayerNotManaged = "invalid_exact_svm_fee_payer_not_managed_by_facilitator"; +export const ErrInvalidPayloadTransaction = "invalid_exact_svm_payload_transaction"; +export const ErrTransactionCouldNotBeDecoded = + "invalid_exact_svm_payload_transaction_could_not_be_decoded"; +export const ErrSignatureInvalid = "invalid_exact_svm_payload_signature_invalid"; +export const ErrExcessiveSigners = "invalid_exact_svm_payload_excessive_signers"; +export const ErrTransactionInstructionsLength = + "invalid_exact_svm_payload_transaction_instructions_length"; +export const ErrComputeLimitInstructionTooHigh = + "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction_too_high"; +export const ErrUnknownFourthInstruction = "invalid_exact_svm_payload_unknown_fourth_instruction"; +export const ErrUnknownFifthInstruction = "invalid_exact_svm_payload_unknown_fifth_instruction"; +export const ErrUnknownSixthInstruction = "invalid_exact_svm_payload_unknown_sixth_instruction"; +export const ErrUnknownSeventhInstruction = "invalid_exact_svm_payload_unknown_seventh_instruction"; +export const ErrUnknownOptionalInstruction = + "invalid_exact_svm_payload_unknown_optional_instruction"; +export const ErrComputeLimitInstruction = + "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction"; +export const ErrComputePriceInstruction = + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction"; +export const ErrComputePriceInstructionTooHigh = + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high"; +export const ErrNoTransferInstruction = "invalid_exact_svm_payload_no_transfer_instruction"; +export const ErrFeePayerTransferringFunds = + "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds"; +export const ErrMintMismatch = "invalid_exact_svm_payload_mint_mismatch"; +export const ErrRecipientMismatch = "invalid_exact_svm_payload_recipient_mismatch"; +export const ErrAmountMismatch = "invalid_exact_svm_payload_amount_mismatch"; +export const ErrInvalidFeePayer = "invalid_exact_svm_invalid_fee_payer"; +export const ErrTransactionSigningFailed = "invalid_exact_svm_transaction_signing_failed"; +export const ErrTransactionSimulationFailed = "invalid_exact_svm_transaction_simulation_failed"; + +// Memo verification errors +export const ErrMemoMismatch = "invalid_exact_svm_payload_memo_mismatch"; +export const ErrMemoCount = "invalid_exact_svm_payload_memo_count"; + +// Settle errors +export const ErrVerificationFailed = "invalid_exact_svm_verification_failed"; +export const ErrFeePayerMismatch = "invalid_exact_svm_fee_payer_mismatch"; +export const ErrTransactionFailed = "invalid_exact_svm_transaction_failed"; +export const ErrDuplicateSettlement = "duplicate_settlement"; +export const ErrPostSettlementTransferNotConfirmed = "post_settlement_transfer_not_confirmed"; + +// Smart wallet verification errors +export const ErrSmartWalletFeePayerNotIsolated = + "invalid_exact_svm_smart_wallet_fee_payer_not_isolated"; +export const ErrSmartWalletMalformedComputeBudget = + "invalid_exact_svm_smart_wallet_malformed_compute_budget"; +export const ErrSmartWalletMalformedComputeLimit = + "invalid_exact_svm_smart_wallet_malformed_compute_limit"; +export const ErrSmartWalletMalformedComputePrice = + "invalid_exact_svm_smart_wallet_malformed_compute_price"; +export const ErrSmartWalletComputeUnitsTooHigh = + "invalid_exact_svm_smart_wallet_compute_units_too_high"; +export const ErrSmartWalletPriorityFeeTooHigh = + "invalid_exact_svm_smart_wallet_priority_fee_too_high"; +export const ErrSmartWalletUnsupportedComputeBudget = + "invalid_exact_svm_smart_wallet_unsupported_compute_budget_instruction"; +export const ErrSmartWalletAltResolutionUnavailable = + "invalid_exact_svm_smart_wallet_alt_resolution_not_available"; +export const ErrSmartWalletAltResolutionFailed = + "invalid_exact_svm_smart_wallet_alt_resolution_failed"; +export const ErrSmartWalletVerificationUnavailable = + "invalid_exact_svm_smart_wallet_verification_not_available"; +export const ErrSmartWalletComputeBudgetViolation = + "invalid_exact_svm_smart_wallet_compute_budget_violation"; +export const ErrSmartWalletSimulationFailed = "invalid_exact_svm_smart_wallet_simulation_failed"; +export const ErrSmartWalletCannotDeriveATA = + "invalid_exact_svm_smart_wallet_cannot_derive_destination_ata"; +export const ErrSmartWalletNoTransferInSimulation = + "invalid_exact_svm_smart_wallet_no_transfer_in_simulation"; +export const ErrSmartWalletTransferMismatch = "invalid_exact_svm_smart_wallet_transfer_mismatch"; +export const ErrSmartWalletMultipleMatchingTransfers = + "invalid_exact_svm_smart_wallet_multiple_matching_transfers"; +export const ErrSmartWalletProgramNotAllowed = "invalid_exact_svm_smart_wallet_program_not_allowed"; + +/** + * Non-terminal settle error reason used when a transaction was broadcast but + * `confirmTransaction` couldn't observe its confirmation in time. Always + * carries the broadcast signature (as `SettleResponse.transaction`) so a + * caller can reconcile onchain, and mirrors `x402.ErrSettlementPending` / + * `evm.ErrSettlementPending` so `x402ResourceServer`'s generic + * single-retry-on-settlement_pending logic recognizes it uniformly across + * schemes/networks. Replaces the former `transaction_failed`/ + * `settlement_confirmation_timeout`-style reasons on the confirm-timeout + * path, which were terminal and gave callers no reconciliation path. + */ +export const ErrSettlementPending = "settlement_pending"; diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts index ca03563ae2..52faa4e149 100644 --- a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts @@ -16,6 +16,7 @@ import { decompileTransactionMessage, getCompiledTransactionMessageDecoder, type Address, + type Transaction, } from "@solana/kit"; import type { PaymentPayload, @@ -24,6 +25,10 @@ import type { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; import { LIGHTHOUSE_PROGRAM_ADDRESS, MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, @@ -35,13 +40,24 @@ import type { ExactSvmPayloadV2 } from "../../types"; import { decodeTransactionFromPayload, getTokenPayerFromTransaction, + recordPendingOrTerminal, transactionMessageHash, + TransactionOnchainFailureError, } from "../../utils"; import { assertSmartWalletLimits, + assertSmartWalletVerifySigner, + resolveAccountKeys, verifySmartWalletTransaction, verifyPostSettlement, + type DecodedTransactionView, + type SmartWalletVerifySigner, + type TransferCheckedInfo, } from "./smartWalletVerification"; +import { verifyRequiredSignatures } from "./signatureVerification"; +import * as Errors from "./errors"; + +const compiledMessageDecoder = getCompiledTransactionMessageDecoder(); /** * Default allowed smart wallet program addresses. @@ -74,6 +90,7 @@ type VerificationPath = "static" | "smartWallet"; type VerifyResult = { response: VerifyResponse; verificationPath: VerificationPath | null; + matchedTransfer?: TransferCheckedInfo; }; /** @@ -89,14 +106,14 @@ type VerifyResult = { * mask the real reason behind a misleading smart_wallet_* error code. */ const LAYOUT_RECOVERABLE_REASONS = new Set([ - "invalid_exact_svm_payload_transaction_instructions_length", - "invalid_exact_svm_payload_no_transfer_instruction", - "invalid_exact_svm_payload_unknown_fourth_instruction", - "invalid_exact_svm_payload_unknown_fifth_instruction", - "invalid_exact_svm_payload_unknown_sixth_instruction", - "invalid_exact_svm_payload_unknown_optional_instruction", - "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + Errors.ErrTransactionInstructionsLength, + Errors.ErrNoTransferInstruction, + Errors.ErrUnknownFourthInstruction, + Errors.ErrUnknownFifthInstruction, + Errors.ErrUnknownSixthInstruction, + Errors.ErrUnknownOptionalInstruction, + Errors.ErrComputeLimitInstruction, + Errors.ErrComputePriceInstruction, ]); /** @@ -170,6 +187,17 @@ export type ExactSvmSchemeOptions = { * Default: unset (no limit, preserving existing behavior) */ maxRequiredSignatures?: number; + + /** + * Lets a retried settle for the same transaction reconcile against an + * already-broadcast signature instead of re-verifying and re-sending (see + * {@link PendingSettlementStore}). Defaults to a fresh in-memory store + * shared across all settle calls on this scheme instance. Inject a + * shared, network-backed implementation (e.g. Redis) for a + * multi-instance facilitator so a settle retry landing on a different + * replica still reconciles correctly. + */ + pendingSettlementStore?: PendingSettlementStore; }; /** @@ -210,6 +238,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { readonly caipFamily = "solana:*"; private readonly settlementCache: SettlementCache; + private readonly pendingStore: PendingSettlementStore; /** * Creates a new ExactSvmScheme instance. @@ -224,6 +253,8 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { private readonly options?: ExactSvmSchemeOptions, ) { this.settlementCache = settlementCache ?? new SettlementCache(); + this.pendingStore = + this.options?.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); // A limit that cannot be compared against is worse than no limit, so reject // it here rather than at verify time. @@ -238,25 +269,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { maxComputeUnits: this.options.smartWalletMaxComputeUnits, maxPriorityFeeMicroLamports: this.options.smartWalletMaxPriorityFeeMicroLamports, }); - - // fetchAddressLookupTables is required too: assertFeePayerIsolated can't - // inspect ALT-resolved accounts without it, so an ALT-using wallet would - // otherwise fail at verify time rather than at construction. - const required = [ - "simulateTransactionWithInnerInstructions", - "getConfirmedTransactionInnerInstructions", - "getTokenAccountBalance", - "fetchAddressLookupTables", - ] as const; - - for (const method of required) { - if (typeof (this.signer as Record)[method] !== "function") { - throw new Error( - `enableSmartWalletVerification requires ${method} on the signer. ` + - `Use toFacilitatorSvmSigner() which provides all required methods.`, - ); - } - } + assertSmartWalletVerifySigner(this.signer); } } @@ -320,34 +333,107 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { ): Promise { const exactSvmPayload = payload.payload as ExactSvmPayloadV2; - const { response: valid, verificationPath } = await this._verify(payload, requirements); - if (!valid.isValid) { + // Decode the transaction to compute the message hash used as the pending- + // settlement/dedup key, up front (no RPC calls) so it's available before + // any verify/sign/send work. A decode failure here just disables the fast + // path — the normal verify call below still produces the correct + // "could not be decoded" rejection. + let txKey: string | undefined; + let decodedTransaction: Transaction | undefined; + try { + decodedTransaction = decodeTransactionFromPayload(exactSvmPayload); + txKey = transactionMessageHash(decodedTransaction); + } catch { + txKey = undefined; + } + + // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot + // 0). Must remain synchronous (before any await) so concurrent settle calls for + // the same payment are caught before any async work begins. + const isCachedDuplicate = txKey ? this.settlementCache.isDuplicate(txKey) : false; + + // Pending-settlement fast path: a prior settle for this exact transaction + // broadcast successfully but its confirmTransaction wait failed. Reconcile + // against the already-broadcast signature instead of re-verifying and + // re-sending: Solana transactions embed a recent blockhash that expires + // (so a resend can fail even when the original is still perfectly valid), + // and if the original actually did land, a second verify's balance-based + // simulation could now spuriously fail (funds already moved). + if (txKey) { + const cachedSignature = await this.pendingStore.get(txKey); + if (cachedSignature) { + // Remove before reconciling (rather than after) so a concurrent + // retry of the same payload misses here instead of also + // reconciling: it falls through to the settlementCache dedup check + // below, which independently rejects it as a duplicate. + await this.pendingStore.delete(txKey); + // Best-effort payer for the response; a decode/parse failure here + // doesn't block reconciliation (the payload already broadcast + // successfully). + let payer = ""; + let isSmartWalletSettlement = false; + try { + payer = getTokenPayerFromTransaction(decodedTransaction!) ?? ""; + // Re-derive which verification path the original settle used; + // verificationPath itself isn't available here. + isSmartWalletSettlement = + !!this.options?.enableSmartWalletVerification && + !this.hasStaticTransferLayout(decodedTransaction!); + } catch { + // Ignore; payer stays "" and isSmartWalletSettlement stays false. + } + return this.reconcilePendingSettlement( + txKey, + cachedSignature, + payer, + requirements.network, + isSmartWalletSettlement, + requirements, + ); + } + } + + if (isCachedDuplicate) { + let payer = ""; + try { + payer = getTokenPayerFromTransaction(decodedTransaction!) || ""; + } catch { + payer = ""; + } return { success: false, network: payload.accepted.network, transaction: "", - errorReason: valid.invalidReason ?? "verification_failed", - payer: valid.payer || "", + errorReason: Errors.ErrDuplicateSettlement, + payer, }; } - // Decode the transaction to compute the message hash used as the cache key. - // Must remain synchronous (before any await) so concurrent settle calls for - // the same payment are caught before any async work begins. - const decodedTx = decodeTransactionFromPayload(exactSvmPayload); - - // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). - const txKey = transactionMessageHash(decodedTx); - if (this.settlementCache.isDuplicate(txKey)) { + const { + response: valid, + verificationPath, + matchedTransfer, + } = await this._verify(payload, requirements); + if (!valid.isValid) { + if (txKey) { + this.settlementCache.delete(txKey); + } return { success: false, network: payload.accepted.network, transaction: "", - errorReason: "duplicate_settlement", + errorReason: valid.invalidReason ?? Errors.ErrVerificationFailed, payer: valid.payer || "", }; } + // Reuses the txKey decoded/hashed synchronously above instead of decoding a + // third time: the transaction content is unchanged, and a decode failure here + // would imply _verify's identical decode already failed and returned isValid:false, + // so this point is unreachable with txKey undefined. The fallback recompute + // only exists to satisfy the type checker without an unsafe non-null assertion. + txKey ??= transactionMessageHash(decodeTransactionFromPayload(exactSvmPayload)); + // Settlements verified through Path 2 (smart wallet) require post-settlement // verification to defend against TOCTOU. _verify reports the path directly, // so we no longer re-decode the transaction to infer it. @@ -355,98 +441,244 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // For smart wallet settlements: record destination ATA balance before sending. // Used as fallback verification if getTransaction has indexing lag. - // Try both SPL Token and Token-2022 programs — the payment may use either. + let signature: string; let balanceBefore: bigint | null = null; - let balanceBeforeTokenProgram: - | typeof TOKEN_PROGRAM_ADDRESS - | typeof TOKEN_2022_PROGRAM_ADDRESS - | null = null; - if (isSmartWalletSettlement && typeof this.signer.getTokenAccountBalance === "function") { - for (const tokenProgram of [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]) { - try { - const [destinationAta] = await findAssociatedTokenPda({ - mint: requirements.asset as Address, - owner: requirements.payTo as Address, - tokenProgram: tokenProgram as unknown as Address, - }); - const balance = await this.signer.getTokenAccountBalance( - destinationAta.toString(), - requirements.network, - ); - if (balance !== null) { - balanceBefore = balance; - balanceBeforeTokenProgram = tokenProgram; - break; // Use whichever ATA has a balance (exists on-chain) - } - } catch { - // ATA doesn't exist for this token program. Try the other. - } - } - } - + let knownDestinationAta: string | null = matchedTransfer?.destination ?? null; + let balanceBeforeTokenProgram: string | null = matchedTransfer?.programId ?? null; try { // Extract feePayer from requirements (already validated in verify) const feePayer = requirements.extra.feePayer as Address; // Sign transaction with the feePayer's signer - const fullySignedTransaction = await this.signer.signTransaction( + const signPromise = this.signer.signTransaction( exactSvmPayload.transaction, feePayer, requirements.network, ); + const balancePromise = + isSmartWalletSettlement && + knownDestinationAta && + typeof this.signer.getTokenAccountBalance === "function" + ? this.signer + .getTokenAccountBalance(knownDestinationAta, requirements.network) + .catch(() => null) + : Promise.resolve(null); + + const [fullySignedTransaction, preBalance] = await Promise.all([signPromise, balancePromise]); + balanceBefore = preBalance; // Send transaction to network - const signature = await this.signer.sendTransaction( - fullySignedTransaction, - requirements.network, - ); - - // Wait for confirmation - await this.signer.confirmTransaction(signature, requirements.network); - - // Post-settlement verification for smart wallet transactions. - // Confirms the TransferChecked actually executed on-chain (TOCTOU defense). - if (isSmartWalletSettlement) { - const signerAddresses = this.signer.getAddresses().map(a => a.toString()); - const postVerify = await verifyPostSettlement( - this.signer, - signature, - requirements.network, - requirements, - signerAddresses, - balanceBefore, - balanceBeforeTokenProgram?.toString() ?? null, - ); - - if (!postVerify.verified) { - return { - success: false, - errorReason: "post_settlement_transfer_not_confirmed", - transaction: signature, - network: payload.accepted.network, - payer: valid.payer || "", - }; - } - } - - return { - success: true, - transaction: signature, - network: payload.accepted.network, - payer: valid.payer, - }; + signature = await this.signer.sendTransaction(fullySignedTransaction, requirements.network); } catch (error) { - // Allow retry before TTL; blockhash may still be valid. + // Never broadcast (or broadcast failed outright): allow retry before TTL; + // blockhash may still be valid. this.settlementCache.delete(txKey); - console.error("Failed to settle transaction:", error); + console.error("Failed to send transaction:", error); return { success: false, - errorReason: "transaction_failed", + errorReason: Errors.ErrTransactionFailed, transaction: "", network: payload.accepted.network, payer: valid.payer || "", }; } + + try { + // Wait for confirmation + await this.signer.confirmTransaction(signature, requirements.network); + } catch (error) { + if (error instanceof TransactionOnchainFailureError) { + // Definite onchain rejection: safe to release the dedup lock so a + // fresh broadcast (new blockhash) isn't blocked by this one. + this.settlementCache.delete(txKey); + console.error("Transaction failed onchain:", error); + return { + success: false, + errorReason: Errors.ErrTransactionFailed, + errorMessage: error.message, + transaction: signature, + network: payload.accepted.network, + payer: valid.payer || "", + }; + } + // Broadcast succeeded but confirmation couldn't be observed in time. + // Non-terminal: leave the dedup lock in place (a fresh broadcast would + // double-spend) and record the signature so a retry reconciles via the + // fast path above instead of re-verifying/re-sending. + console.error("Failed to confirm transaction:", error); + return recordPendingOrTerminal( + this.pendingStore, + txKey, + signature, + valid.payer || "", + payload.accepted.network, + Errors.ErrSettlementPending, + Errors.ErrTransactionFailed, + error, + ); + } + try { + await this.pendingStore.delete(txKey); + } catch { + // Best-effort cleanup; the confirmed settlement below is correct + // regardless and must not be masked by a storage hiccup. A stale entry + // merely lingers until TTL expiry. + } + + // Post-settlement verification for smart wallet transactions. + // Confirms the TransferChecked actually executed on-chain (TOCTOU defense). + if (isSmartWalletSettlement) { + const signerAddresses = this.signer.getAddresses().map(a => a.toString()); + const postVerify = await verifyPostSettlement( + this.signer, + signature, + requirements.network, + requirements, + signerAddresses, + balanceBefore, + balanceBeforeTokenProgram, + knownDestinationAta, + ); + + if (!postVerify.verified) { + return { + success: false, + errorReason: Errors.ErrPostSettlementTransferNotConfirmed, + transaction: signature, + network: payload.accepted.network, + payer: valid.payer || "", + }; + } + } + + return { + success: true, + transaction: signature, + network: payload.accepted.network, + payer: valid.payer, + }; + } + + /** + * Handles a `PendingSettlementStore` cache hit: a prior `settle` call for + * this transaction (keyed by `txKey`, the message hash) already broadcast + * `cachedSignature` but couldn't confirm it before returning + * `settlement_pending`. Re-awaits confirmation of that same signature + * rather than re-verifying/re-signing/re-sending — see the fast-path + * comment in {@link settle} for why re-sending is unsafe here. + * + * @param txKey - Message-hash key this transaction is cached under + * @param cachedSignature - The previously broadcast signature + * @param payer - Best-effort payer address for the response + * @param network - The network the transaction was broadcast to + * @param isSmartWalletSettlement - Whether the original settlement was verified via + * Path 2 (smart wallet), requiring the same post-settlement TOCTOU check {@link settle} + * runs on its non-cached path + * @param requirements - Payment requirements, needed by {@link verifyPostSettlement} + * when isSmartWalletSettlement is true + * @returns Promise resolving to the reconciled settlement response + */ + private async reconcilePendingSettlement( + txKey: string, + cachedSignature: string, + payer: string, + network: PaymentRequirements["network"], + isSmartWalletSettlement: boolean, + requirements: PaymentRequirements, + ): Promise { + try { + await this.signer.confirmTransaction(cachedSignature, network); + } catch (error) { + if (error instanceof TransactionOnchainFailureError) { + // Definite onchain rejection: release the dedup lock set by the + // original (now-returned) settle call so a fresh broadcast isn't + // blocked by this one. + this.settlementCache.delete(txKey); + console.error("Transaction failed onchain during reconciliation:", error); + return { + success: false, + errorReason: Errors.ErrTransactionFailed, + errorMessage: error.message, + transaction: cachedSignature, + network, + payer, + }; + } + return recordPendingOrTerminal( + this.pendingStore, + txKey, + cachedSignature, + payer, + network, + Errors.ErrSettlementPending, + Errors.ErrTransactionFailed, + error, + ); + } + try { + await this.pendingStore.delete(txKey); + } catch { + // Best-effort cleanup; see the confirmTransaction catch above for why + // a storage hiccup must not mask a confirmed settlement. + } + + if (isSmartWalletSettlement) { + const signerAddresses = this.signer.getAddresses().map(a => a.toString()); + const postVerify = await verifyPostSettlement( + this.signer, + cachedSignature, + network, + requirements, + signerAddresses, + // No pre-broadcast balance snapshot is available here, so only the + // innerInstructions method applies; the balance-delta fallback is skipped. + null, + null, + ); + if (!postVerify.verified) { + return { + success: false, + errorReason: Errors.ErrPostSettlementTransferNotConfirmed, + transaction: cachedSignature, + network, + payer, + }; + } + } + + return { + success: true, + transaction: cachedSignature, + network, + payer, + }; + } + + /** + * Cheap, local structural check for whether a decoded transaction matches + * Path 1's static positional layout (compute budget instructions followed + * by a TransferChecked at index 2). Used to re-derive which verification + * path a pending settlement originally used, without re-simulating. + * + * @param transaction - Decoded transaction to inspect + * @returns Whether the transaction has Path 1's static transfer shape + */ + private hasStaticTransferLayout(transaction: Transaction): boolean { + const compiled = compiledMessageDecoder.decode(transaction.messageBytes); + const instructions = decompileTransactionMessage(compiled).instructions ?? []; + if (instructions.length < 3 || instructions.length > 7) { + return false; + } + const transferIx = instructions[2]; + const programAddress = transferIx.programAddress.toString(); + if ( + programAddress !== TOKEN_PROGRAM_ADDRESS.toString() && + programAddress !== TOKEN_2022_PROGRAM_ADDRESS.toString() + ) { + return false; + } + const ixData = transferIx.data; + return !!ixData && ixData.length >= 10 && ixData[0] === IX_TOKEN_TRANSFER_CHECKED; } /** @@ -469,14 +701,14 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // Step 1: Validate Payment Requirements if (payload.accepted.scheme !== "exact" || requirements.scheme !== "exact") { return { - response: { isValid: false, invalidReason: "unsupported_scheme", payer: "" }, + response: { isValid: false, invalidReason: Errors.ErrUnsupportedScheme, payer: "" }, verificationPath: null, }; } if (payload.accepted.network !== requirements.network) { return { - response: { isValid: false, invalidReason: "network_mismatch", payer: "" }, + response: { isValid: false, invalidReason: Errors.ErrNetworkMismatch, payer: "" }, verificationPath: null, }; } @@ -485,7 +717,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { return { response: { isValid: false, - invalidReason: "invalid_exact_svm_payload_missing_fee_payer", + invalidReason: Errors.ErrMissingFeePayer, payer: "", }, verificationPath: null, @@ -498,7 +730,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { return { response: { isValid: false, - invalidReason: "fee_payer_not_managed_by_facilitator", + invalidReason: Errors.ErrFeePayerNotManaged, payer: "", }, verificationPath: null, @@ -513,7 +745,21 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { return { response: { isValid: false, - invalidReason: "invalid_exact_svm_payload_transaction_could_not_be_decoded", + invalidReason: Errors.ErrTransactionCouldNotBeDecoded, + payer: "", + }, + verificationPath: null, + }; + } + + let compiled; + try { + compiled = compiledMessageDecoder.decode(transaction.messageBytes); + } catch { + return { + response: { + isValid: false, + invalidReason: Errors.ErrTransactionCouldNotBeDecoded, payer: "", }, verificationPath: null, @@ -526,15 +772,69 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // recoverable via the Path 2 fallthrough. const maxRequiredSignatures = this.options?.maxRequiredSignatures; if (maxRequiredSignatures !== undefined) { - const compiledForSignerCheck = getCompiledTransactionMessageDecoder().decode( - transaction.messageBytes, - ); - const numRequiredSignatures = compiledForSignerCheck.header.numSignerAccounts; + const numRequiredSignatures = compiled.header.numSignerAccounts; if (numRequiredSignatures > maxRequiredSignatures) { return { response: { isValid: false, - invalidReason: "invalid_exact_svm_payload_excessive_signers", + invalidReason: Errors.ErrExcessiveSigners, + payer: "", + }, + verificationPath: null, + }; + } + } + + const signatureCheck = await verifyRequiredSignatures( + transaction, + compiled, + requirements.extra.feePayer, + ); + if (!signatureCheck.ok) { + return { + response: { isValid: false, invalidReason: signatureCheck.invalidReason, payer: "" }, + verificationPath: null, + }; + } + + // Resolve address lookup tables once and reuse the map for every decompile. + // Standard-wallet payments have no lookups and pay no extra RPC. + let lookupMap: Record | undefined; + const lookups = + "addressTableLookups" in compiled && Array.isArray(compiled.addressTableLookups) + ? compiled.addressTableLookups + : []; + if (lookups.length > 0) { + if (typeof this.signer.fetchAddressLookupTables !== "function") { + return { + response: { + isValid: false, + invalidReason: + `${Errors.ErrSmartWalletAltResolutionUnavailable}: transaction uses Address Lookup Tables ` + + "but signer does not implement fetchAddressLookupTables", + payer: "", + }, + verificationPath: null, + }; + } + try { + const altAddresses = lookups.map(l => + (l as { lookupTableAddress: { toString(): string } }).lookupTableAddress.toString(), + ); + const resolved = await this.signer.fetchAddressLookupTables( + altAddresses, + requirements.network, + ); + lookupMap = {}; + for (const [key, addresses] of Object.entries(resolved)) { + lookupMap[key] = addresses.map(a => a as Address); + } + } catch (error) { + return { + response: { + isValid: false, + invalidReason: + error instanceof Error ? error.message : Errors.ErrSmartWalletAltResolutionFailed, payer: "", }, verificationPath: null, @@ -542,9 +842,33 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { } } + let decompiled; + try { + decompiled = lookupMap + ? decompileTransactionMessage(compiled, { addressesByLookupTableAddress: lookupMap }) + : decompileTransactionMessage(compiled); + } catch { + return { + response: { + isValid: false, + invalidReason: Errors.ErrTransactionCouldNotBeDecoded, + payer: "", + }, + verificationPath: null, + }; + } + + const decodedView: DecodedTransactionView = { + transaction, + compiled, + decompiled, + resolvedAccountKeys: resolveAccountKeys(compiled, lookupMap), + }; + // ─── Path 1: Static validation (standard wallets) ─────────────────── const staticResult = await this.verifyStaticPath( transaction, + decompiled, exactSvmPayload, requirements, signerAddresses, @@ -572,15 +896,13 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { this.options.smartWalletAllowedPrograms ?? DEFAULT_SMART_WALLET_ALLOWED_PROGRAMS, ); - const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); - const decompiledForCheck = decompileTransactionMessage(compiled); // ComputeBudget and Memo are category-exempt: compute budget is validated // by caps, and memo content is verified by Path 2's Step 4a. Neither is a // wallet program, so they must not be subject to the wallet-program // allowlist. Explicit for-loop instead of .map().filter() because strict // TypeScript inference on decompileTransactionMessage's return type is // sensitive to which @solana/kit version resolves across peer deps. - const rawInstructions = (decompiledForCheck.instructions ?? []) as ReadonlyArray<{ + const rawInstructions = (decompiled.instructions ?? []) as ReadonlyArray<{ programAddress: { toString(): string }; }>; const topLevelPrograms: string[] = []; @@ -597,7 +919,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { return { response: { isValid: false, - invalidReason: `smart_wallet_program_not_allowed: ${disallowedProgram}`, + invalidReason: `${Errors.ErrSmartWalletProgramNotAllowed}: ${disallowedProgram}`, payer: "", }, verificationPath: null, @@ -608,7 +930,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { const smartWalletResult = await verifySmartWalletTransaction( exactSvmPayload.transaction, requirements, - this.signer, + this.signer as SmartWalletVerifySigner, feePayer, signerAddresses, { @@ -616,10 +938,13 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { maxComputeUnits: this.options.smartWalletMaxComputeUnits, maxPriorityFeeMicroLamports: this.options.smartWalletMaxPriorityFeeMicroLamports, }, + decodedView, ); + const { matchedTransfer, ...response } = smartWalletResult; return { - response: smartWalletResult, + response, verificationPath: smartWalletResult.isValid ? "smartWallet" : null, + matchedTransfer, }; } @@ -632,6 +957,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { * transfer details. Unchanged from the original implementation. * * @param transaction - Decoded transaction to verify + * @param decompiled - Pre-decompiled message (lookups already resolved) * @param exactSvmPayload - The raw SVM payload containing the base64 transaction * @param requirements - Payment requirements to verify against * @param signerAddresses - Facilitator signer addresses (for self-spend protection) @@ -639,12 +965,11 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { */ private async verifyStaticPath( transaction: ReturnType, + decompiled: ReturnType, exactSvmPayload: ExactSvmPayloadV2, requirements: PaymentRequirements, signerAddresses: string[], ): Promise { - const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); - const decompiled = decompileTransactionMessage(compiled); const instructions = decompiled.instructions ?? []; // Allow 3-7 instructions: @@ -658,7 +983,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (instructions.length < 3 || instructions.length > 7) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_transaction_instructions_length", + invalidReason: Errors.ErrTransactionInstructionsLength, payer: "", }; } @@ -680,7 +1005,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (!payer) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", + invalidReason: Errors.ErrNoTransferInstruction, payer: "", }; } @@ -695,7 +1020,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { ) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", + invalidReason: Errors.ErrNoTransferInstruction, payer, }; } @@ -705,7 +1030,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (!ixData || ixData.length < 10 || ixData[0] !== IX_TOKEN_TRANSFER_CHECKED) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", + invalidReason: Errors.ErrNoTransferInstruction, payer, }; } @@ -721,7 +1046,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { } catch { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", + invalidReason: Errors.ErrNoTransferInstruction, payer, }; } @@ -732,7 +1057,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (signerAddresses.includes(authorityAddress)) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + invalidReason: Errors.ErrFeePayerTransferringFunds, payer, }; } @@ -742,7 +1067,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (mintAddress !== requirements.asset) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_mint_mismatch", + invalidReason: Errors.ErrMintMismatch, payer, }; } @@ -762,14 +1087,14 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (destATA !== expectedDestATA.toString()) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_recipient_mismatch", + invalidReason: Errors.ErrRecipientMismatch, payer, }; } } catch { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_recipient_mismatch", + invalidReason: Errors.ErrRecipientMismatch, payer, }; } @@ -779,7 +1104,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (amount !== BigInt(requirements.amount)) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_amount_mismatch", + invalidReason: Errors.ErrAmountMismatch, payer, }; } @@ -788,10 +1113,10 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // Allowed optional programs: Lighthouse (wallet protection) and Memo (uniqueness) const optionalInstructions = instructions.slice(3); const invalidReasonByIndex = [ - "invalid_exact_svm_payload_unknown_fourth_instruction", - "invalid_exact_svm_payload_unknown_fifth_instruction", - "invalid_exact_svm_payload_unknown_sixth_instruction", - "invalid_exact_svm_payload_unknown_seventh_instruction", + Errors.ErrUnknownFourthInstruction, + Errors.ErrUnknownFifthInstruction, + Errors.ErrUnknownSixthInstruction, + Errors.ErrUnknownSeventhInstruction, ]; for (let i = 0; i < optionalInstructions.length; i += 1) { @@ -805,8 +1130,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { return { isValid: false, - invalidReason: - invalidReasonByIndex[i] ?? "invalid_exact_svm_payload_unknown_optional_instruction", + invalidReason: invalidReasonByIndex[i] ?? Errors.ErrUnknownOptionalInstruction, payer, }; } @@ -820,7 +1144,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (memoInstructions.length !== 1) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_memo_count", + invalidReason: Errors.ErrMemoCount, payer, }; } @@ -829,29 +1153,22 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { if (actualMemo !== expectedMemo) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_memo_mismatch", + invalidReason: Errors.ErrMemoMismatch, payer, }; } } - // Step 6: Sign and Simulate Transaction + // Step 6: Simulate Transaction // CRITICAL: Simulation proves transaction will succeed (catches insufficient balance, invalid accounts, etc) + // Signatures are verified locally; the fee-payer slot is unsigned until settle. try { - const feePayer = requirements.extra!.feePayer as Address; - - const fullySignedTransaction = await this.signer.signTransaction( - exactSvmPayload.transaction, - feePayer, - requirements.network, - ); - - await this.signer.simulateTransaction(fullySignedTransaction, requirements.network); + await this.signer.simulateTransaction(exactSvmPayload.transaction, requirements.network); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { isValid: false, - invalidReason: "transaction_simulation_failed", + invalidReason: Errors.ErrTransactionSimulationFailed, invalidMessage: errorMessage, payer, }; @@ -882,9 +1199,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { !instruction.data || instruction.data[0] !== 2 // discriminator for SetComputeUnitLimit ) { - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", - ); + throw new Error(Errors.ErrComputeLimitInstruction); } try { @@ -892,17 +1207,13 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { const maxComputeUnits = this.options?.maxComputeUnits; if (maxComputeUnits !== undefined && parsedInstruction.data.units > maxComputeUnits) { - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction_too_high", - ); + throw new Error(Errors.ErrComputeLimitInstructionTooHigh); } } catch (error) { if (error instanceof Error && error.message.includes("too_high")) { throw error; } - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", - ); + throw new Error(Errors.ErrComputeLimitInstruction); } } @@ -924,9 +1235,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { !instruction.data || instruction.data[0] !== 3 // discriminator for SetComputeUnitPrice ) { - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", - ); + throw new Error(Errors.ErrComputePriceInstruction); } try { @@ -936,17 +1245,13 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { const maxPriorityFee = this.options?.maxPriorityFeeMicroLamports ?? MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS; if (parsedInstruction.data.microLamports > BigInt(maxPriorityFee)) { - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", - ); + throw new Error(Errors.ErrComputePriceInstructionTooHigh); } } catch (error) { if (error instanceof Error && error.message.includes("too_high")) { throw error; } - throw new Error( - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", - ); + throw new Error(Errors.ErrComputePriceInstruction); } } } diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/signatureVerification.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/signatureVerification.ts new file mode 100644 index 0000000000..e6648b9ebb --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/signatureVerification.ts @@ -0,0 +1,89 @@ +/** + * Local Ed25519 verification of required transaction signers. + * + * Replaces RPC-side `sigVerify` during facilitator verify: the fee-payer slot + * is unsigned until settle, so simulation runs with sigVerify off. Every other + * required signer is checked here over `transaction.messageBytes`. + */ + +import { getAddressEncoder, verifySignature, type Address, type Transaction } from "@solana/kit"; +import * as Errors from "./errors"; + +const addressEncoder = getAddressEncoder(); + +export type CompiledSignerAccounts = { + header: { numSignerAccounts: number }; + staticAccounts: readonly { toString(): string }[]; +}; + +export type SignatureVerificationResult = { ok: true } | { ok: false; invalidReason: string }; + +/** + * Asserts account 0 is the advertised fee payer, then verifies Ed25519 + * signatures for signer indices 1..numSignerAccounts-1. + * + * Index 0 is skipped: the facilitator fills that slot at settle time. + * + * @param transaction - Decoded transaction whose signatures to check + * @param compiled - Compiled message (header + static accounts) + * @param expectedFeePayer - Fee payer from payment requirements + * @returns ok, or an invalid reason + */ +export async function verifyRequiredSignatures( + transaction: Transaction, + compiled: CompiledSignerAccounts, + expectedFeePayer: string, +): Promise { + const feePayerAccount = compiled.staticAccounts[0]?.toString(); + if (!feePayerAccount || feePayerAccount !== expectedFeePayer) { + return { ok: false, invalidReason: Errors.ErrFeePayerMismatch }; + } + + const numSigners = compiled.header.numSignerAccounts; + const checks: Promise[] = []; + + for (let i = 1; i < numSigners; i++) { + const address = compiled.staticAccounts[i]?.toString(); + if (!address) { + return { ok: false, invalidReason: Errors.ErrSignatureInvalid }; + } + + const signature = transaction.signatures[address as Address]; + if (!signature || signature.length !== 64) { + return { ok: false, invalidReason: Errors.ErrSignatureInvalid }; + } + + checks.push(verifyEd25519(address, signature, transaction.messageBytes)); + } + + const results = await Promise.all(checks); + if (results.some(valid => !valid)) { + return { ok: false, invalidReason: Errors.ErrSignatureInvalid }; + } + + return { ok: true }; +} + +/** + * Verifies one Ed25519 signature over the transaction message bytes. + * + * @param address - Base58 signer address (raw 32-byte public key) + * @param signature - 64-byte Ed25519 signature + * @param messageBytes - Transaction message bytes the signature covers + * @returns Whether the signature is valid + */ +async function verifyEd25519( + address: string, + signature: Uint8Array, + messageBytes: Uint8Array, +): Promise { + try { + const publicKeyBytes = addressEncoder.encode(address as Address); + const publicKey = await crypto.subtle.importKey("raw", publicKeyBytes, "Ed25519", false, [ + "verify", + ]); + return await verifySignature(publicKey, signature, messageBytes); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/smartWalletVerification.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/smartWalletVerification.ts index c178943913..74997cd59d 100644 --- a/typescript/packages/mechanisms/svm/src/exact/facilitator/smartWalletVerification.ts +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/smartWalletVerification.ts @@ -21,6 +21,7 @@ import type { PaymentRequirements, VerifyResponse } from "@x402/core/types"; import { MEMO_PROGRAM_ADDRESS } from "../../constants"; import type { FacilitatorSvmSigner, SvmInnerInstructionsResult } from "../../signer"; import { decodeTransactionFromPayload } from "../../utils"; +import * as Errors from "./errors"; const DEFAULT_SMART_WALLET_MAX_COMPUTE_UNITS = 400_000; const DEFAULT_SMART_WALLET_MAX_PRIORITY_FEE_MICROLAMPORTS = 50_000; @@ -39,6 +40,47 @@ export type SmartWalletLimits = { maxPriorityFeeMicroLamports?: number; }; +/** + * {@link FacilitatorSvmSigner} narrowed to the optional caps Path 2 + * (simulation-based smart wallet payment verification) requires at runtime. + */ +export type SmartWalletVerifySigner = FacilitatorSvmSigner & { + simulateTransactionWithInnerInstructions: NonNullable< + FacilitatorSvmSigner["simulateTransactionWithInnerInstructions"] + >; + getConfirmedTransactionInnerInstructions: NonNullable< + FacilitatorSvmSigner["getConfirmedTransactionInnerInstructions"] + >; + getTokenAccountBalance: NonNullable; + fetchAddressLookupTables: NonNullable; +}; + +const SMART_WALLET_VERIFY_METHODS = [ + "simulateTransactionWithInnerInstructions", + "getConfirmedTransactionInnerInstructions", + "getTokenAccountBalance", + "fetchAddressLookupTables", +] as const satisfies readonly (keyof SmartWalletVerifySigner)[]; + +/** + * Assert a facilitator signer exposes every optional cap smart wallet payment + * verification needs. + * + * @param signer - Facilitator signer to validate + * @param label - Scheme or component name for error messages + * @throws Error when a required capability is missing + */ +export function assertSmartWalletVerifySigner( + signer: FacilitatorSvmSigner, + label = "ExactSvmScheme", +): asserts signer is SmartWalletVerifySigner { + for (const method of SMART_WALLET_VERIFY_METHODS) { + if (typeof signer[method] !== "function") { + throw new Error(`${label} requires ${method} on the signer.`); + } + } +} + /** * Rejects smart-wallet limits that cannot be compared safely. * @@ -71,6 +113,75 @@ export type TransferCheckedInfo = { authority: string; }; +/** + * Instruction bytes as kit exposes them (ReadonlyUint8Array) plus a plain + * Uint8Array. Only length/index/DataView fields are required. + */ +type InstructionDataBytes = { + readonly [index: number]: number; + readonly length: number; + readonly buffer: ArrayBufferLike; + readonly byteOffset: number; + readonly byteLength: number; +}; + +export type DecodedInstructionView = { + programAddress: { toString(): string }; + accounts?: ReadonlyArray<{ address: { toString(): string } }>; + data?: InstructionDataBytes; +}; + +/** + * Pre-decoded transaction view shared by Path 1 and Path 2 so the message is + * decompiled once per verify, including any resolved address lookup tables. + */ +export type DecodedTransactionView = { + transaction: Transaction; + compiled: { + staticAccounts?: readonly { toString(): string }[]; + addressTableLookups?: ReadonlyArray<{ + lookupTableAddress: { toString(): string }; + writableIndexes: readonly number[]; + readonlyIndexes: readonly number[]; + }>; + }; + decompiled: { + instructions?: ReadonlyArray; + }; + resolvedAccountKeys: readonly string[]; +}; + +/** + * Builds the full loaded-account list: static keys, then writable lookup + * addresses, then readonly lookup addresses. Inner-instruction indices + * address this combined list, not static accounts alone. + * + * @param compiled - Compiled transaction message + * @param lookupMap - Resolved ALT address → account list, if any + * @returns Ordered account keys matching the Solana runtime load order + */ +export function resolveAccountKeys( + compiled: DecodedTransactionView["compiled"], + lookupMap?: Record, +): string[] { + const keys = (compiled.staticAccounts ?? []).map(a => a.toString()); + const lookups = compiled.addressTableLookups ?? []; + if (lookups.length === 0) return keys; + + const writable: string[] = []; + const readonly: string[] = []; + for (const lookup of lookups) { + const table = lookupMap?.[lookup.lookupTableAddress.toString()] ?? []; + for (const idx of lookup.writableIndexes ?? []) { + if (table[idx]) writable.push(table[idx]); + } + for (const idx of lookup.readonlyIndexes ?? []) { + if (table[idx]) readonly.push(table[idx]); + } + } + return [...keys, ...writable, ...readonly]; +} + /** * Asserts the fee payer does NOT appear in any instruction's accounts or as a * program ID. If the fee payer is never referenced in any instruction, the @@ -102,7 +213,7 @@ export async function assertFeePayerIsolated( // Resolve ALTs before decompiling so all accounts are visible if (!signer || !network || typeof signer.fetchAddressLookupTables !== "function") { throw new Error( - "smart_wallet_alt_resolution_not_available: transaction uses Address Lookup Tables " + + `${Errors.ErrSmartWalletAltResolutionUnavailable}: transaction uses Address Lookup Tables ` + "but signer does not implement fetchAddressLookupTables", ); } @@ -123,12 +234,23 @@ export async function assertFeePayerIsolated( decompiled = decompileTransactionMessage(compiled); } - const instructions = decompiled.instructions ?? []; + assertFeePayerIsolatedFromInstructions(decompiled.instructions ?? [], feePayerAddress); +} +/** + * Asserts the fee payer does not appear in already-decompiled instructions. + * + * @param instructions - Decompiled top-level instructions + * @param feePayerAddress - Facilitator fee payer that must remain isolated + */ +export function assertFeePayerIsolatedFromInstructions( + instructions: ReadonlyArray, + feePayerAddress: string, +): void { for (const ix of instructions) { if (ix.programAddress.toString() === feePayerAddress) { throw new Error( - `smart_wallet_fee_payer_not_isolated: fee payer ${feePayerAddress} invoked as program`, + `${Errors.ErrSmartWalletFeePayerNotIsolated}: fee payer ${feePayerAddress} invoked as program`, ); } @@ -136,7 +258,7 @@ export async function assertFeePayerIsolated( for (const account of accounts) { if (account.address.toString() === feePayerAddress) { throw new Error( - `smart_wallet_fee_payer_not_isolated: fee payer ${feePayerAddress} appears in instruction accounts (program: ${ix.programAddress})`, + `${Errors.ErrSmartWalletFeePayerNotIsolated}: fee payer ${feePayerAddress} appears in instruction accounts (program: ${ix.programAddress})`, ); } } @@ -164,29 +286,50 @@ export function validateComputeBudgetLimits( const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); const decompiled = decompileTransactionMessage(compiled); - const instructions = decompiled.instructions ?? []; + validateComputeBudgetLimitsFromInstructions(decompiled.instructions ?? [], { + maxComputeUnits: maxCU, + maxPriorityFeeMicroLamports: maxPriorityFee, + }); +} + +/** + * Validates ComputeBudget instructions already decompiled from the message. + * + * @param instructions - Decompiled top-level instructions + * @param limits - Resolved compute budget caps (defaults already applied) + * @param limits.maxComputeUnits - Maximum allowed compute units + * @param limits.maxPriorityFeeMicroLamports - Maximum allowed priority fee + */ +export function validateComputeBudgetLimitsFromInstructions( + instructions: ReadonlyArray, + limits: { maxComputeUnits: number; maxPriorityFeeMicroLamports: number }, +): void { + const maxCU = limits.maxComputeUnits; + const maxPriorityFee = limits.maxPriorityFeeMicroLamports; for (const ix of instructions) { if (ix.programAddress.toString() !== COMPUTE_BUDGET_PROGRAM_ADDRESS.toString()) continue; const data = ix.data; if (!data || data.length === 0) { - throw new Error("smart_wallet_malformed_compute_budget: empty instruction data"); + throw new Error(`${Errors.ErrSmartWalletMalformedComputeBudget}: empty instruction data`); } if (data[0] === IX_SET_COMPUTE_UNIT_LIMIT) { if (data.length < 5) { - throw new Error("smart_wallet_malformed_compute_limit"); + throw new Error(Errors.ErrSmartWalletMalformedComputeLimit); } const units = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(1, true); if (units > maxCU) { - throw new Error(`smart_wallet_compute_units_too_high: ${units} exceeds max ${maxCU}`); + throw new Error( + `${Errors.ErrSmartWalletComputeUnitsTooHigh}: ${units} exceeds max ${maxCU}`, + ); } continue; } if (data[0] === IX_SET_COMPUTE_UNIT_PRICE) { if (data.length < 9) { - throw new Error("smart_wallet_malformed_compute_price"); + throw new Error(Errors.ErrSmartWalletMalformedComputePrice); } const microLamports = new DataView( data.buffer, @@ -195,7 +338,7 @@ export function validateComputeBudgetLimits( ).getBigUint64(1, true); if (microLamports > BigInt(maxPriorityFee)) { throw new Error( - `smart_wallet_priority_fee_too_high: ${microLamports} exceeds max ${maxPriorityFee}`, + `${Errors.ErrSmartWalletPriorityFeeTooHigh}: ${microLamports} exceeds max ${maxPriorityFee}`, ); } continue; @@ -205,7 +348,7 @@ export function validateComputeBudgetLimits( // Other ComputeBudget types (RequestHeapFrame, SetLoadedAccountsDataSizeLimit, etc.) // are rejected because they expand execution surface without being necessary // for payment outcome verification. - throw new Error(`smart_wallet_unsupported_compute_budget_instruction: type ${data[0]}`); + throw new Error(`${Errors.ErrSmartWalletUnsupportedComputeBudget}: type ${data[0]}`); } } @@ -292,6 +435,15 @@ export type SmartWalletOptions = SmartWalletLimits & { enabled: boolean; }; +/** + * Verify response plus the matched TransferChecked when Path 2 succeeds. + * Used by settle to fetch one pre-balance and pass the known ATA into + * post-settlement verification. + */ +export type SmartWalletVerifyResult = VerifyResponse & { + matchedTransfer?: TransferCheckedInfo; +}; + /** * Full smart wallet verification pipeline. * Called when static verification rejects a transaction due to unknown programs. @@ -303,80 +455,98 @@ export type SmartWalletOptions = SmartWalletLimits & { * * @param transactionBase64 - Base64 encoded transaction * @param requirements - Payment requirements to verify against - * @param signer - Facilitator signer (must implement simulateTransactionWithInnerInstructions) + * @param signer - Facilitator signer (must implement simulation verification caps) * @param feePayerAddress - Facilitator fee payer address * @param signerAddresses - All facilitator signer addresses (for self-spend protection) * @param options - Optional operator-configurable limits + * @param decoded - Pre-decoded transaction (avoids a second decode/decompile) * @returns Verification result */ export async function verifySmartWalletTransaction( transactionBase64: string, requirements: PaymentRequirements, - signer: FacilitatorSvmSigner, + signer: SmartWalletVerifySigner, feePayerAddress: string, signerAddresses: readonly string[], options?: SmartWalletOptions, -): Promise { + decoded?: DecodedTransactionView, +): Promise { // Configuration errors must propagate to the operator rather than being // reported to the payer as a transaction verification failure. assertSmartWalletLimits(options); - const transaction = decodeTransactionFromPayload({ transaction: transactionBase64 }); + const transaction = + decoded?.transaction ?? decodeTransactionFromPayload({ transaction: transactionBase64 }); + const instructions = decoded?.decompiled.instructions; // 1. Fee payer must not appear in any instruction's accounts. try { - await assertFeePayerIsolated(transaction, feePayerAddress, signer, requirements.network); + if (instructions) { + assertFeePayerIsolatedFromInstructions(instructions, feePayerAddress); + } else { + await assertFeePayerIsolated(transaction, feePayerAddress, signer, requirements.network); + } } catch (error) { return { isValid: false, - invalidReason: error instanceof Error ? error.message : "smart_wallet_fee_payer_not_isolated", + invalidReason: + error instanceof Error ? error.message : Errors.ErrSmartWalletFeePayerNotIsolated, payer: "", }; } // 2. Compute budget caps still apply (operator-configurable). try { - validateComputeBudgetLimits(transaction, { - maxComputeUnits: options?.maxComputeUnits, - maxPriorityFeeMicroLamports: options?.maxPriorityFeeMicroLamports, - }); + if (instructions) { + const maxCU = options?.maxComputeUnits ?? DEFAULT_SMART_WALLET_MAX_COMPUTE_UNITS; + const maxPriorityFee = + options?.maxPriorityFeeMicroLamports ?? DEFAULT_SMART_WALLET_MAX_PRIORITY_FEE_MICROLAMPORTS; + validateComputeBudgetLimitsFromInstructions(instructions, { + maxComputeUnits: maxCU, + maxPriorityFeeMicroLamports: maxPriorityFee, + }); + } else { + validateComputeBudgetLimits(transaction, { + maxComputeUnits: options?.maxComputeUnits, + maxPriorityFeeMicroLamports: options?.maxPriorityFeeMicroLamports, + }); + } } catch (error) { return { isValid: false, invalidReason: - error instanceof Error ? error.message : "smart_wallet_compute_budget_violation", + error instanceof Error ? error.message : Errors.ErrSmartWalletComputeBudgetViolation, payer: "", }; } // 3. Simulate with inner instructions. - if (typeof signer.simulateTransactionWithInnerInstructions !== "function") { - return { - isValid: false, - invalidReason: "smart_wallet_verification_not_available", - payer: "", - }; - } - let simResult: SvmInnerInstructionsResult; try { simResult = await signer.simulateTransactionWithInnerInstructions( transactionBase64, - feePayerAddress as Address, requirements.network, ); } catch (error) { return { isValid: false, - invalidReason: `smart_wallet_simulation_failed: ${error instanceof Error ? error.message : String(error)}`, + invalidReason: `${Errors.ErrSmartWalletSimulationFailed}: ${error instanceof Error ? error.message : String(error)}`, payer: "", }; } // 4. Extract TransferChecked from top-level instructions and CPI inner instructions. - const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); - const decompiled = decompileTransactionMessage(compiled); - const accountKeys = (compiled.staticAccounts ?? []).map(String); + const decompiledInstructions = + instructions ?? + decompileTransactionMessage( + getCompiledTransactionMessageDecoder().decode(transaction.messageBytes), + ).instructions ?? + []; + const accountKeys = + decoded?.resolvedAccountKeys ?? + ( + getCompiledTransactionMessageDecoder().decode(transaction.messageBytes).staticAccounts ?? [] + ).map(String); // 4a. Verify memo content matches extra.memo when present. // Mirrors Path 1's Step 5b enforcement so a seller-required memo cannot be @@ -384,13 +554,13 @@ export async function verifySmartWalletTransaction( // top-level instruction in both paths; wallet programs do not wrap it via CPI. const expectedMemo = requirements.extra?.memo as string | undefined; if (expectedMemo) { - const memoInstructions = (decompiled.instructions ?? []).filter( + const memoInstructions = decompiledInstructions.filter( ix => ix.programAddress.toString() === MEMO_PROGRAM_ADDRESS, ); if (memoInstructions.length !== 1) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_memo_count", + invalidReason: Errors.ErrMemoCount, payer: "", }; } @@ -399,7 +569,7 @@ export async function verifySmartWalletTransaction( if (actualMemo !== expectedMemo) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_memo_mismatch", + invalidReason: Errors.ErrMemoMismatch, payer: "", }; } @@ -407,7 +577,7 @@ export async function verifySmartWalletTransaction( const allTransfers: TransferCheckedInfo[] = []; - for (const ix of decompiled.instructions ?? []) { + for (const ix of decompiledInstructions) { const progId = ix.programAddress.toString(); if (!TOKEN_PROGRAMS.has(progId)) continue; const data = ix.data; @@ -436,7 +606,7 @@ export async function verifySmartWalletTransaction( if (signerAddresses.includes(t.authority)) { return { isValid: false, - invalidReason: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + invalidReason: Errors.ErrFeePayerTransferringFunds, payer: t.authority, }; } @@ -462,7 +632,7 @@ export async function verifySmartWalletTransaction( if (expectedATAs.size === 0) { return { isValid: false, - invalidReason: "smart_wallet_cannot_derive_destination_ata", + invalidReason: Errors.ErrSmartWalletCannotDeriveATA, payer: "", }; } @@ -477,11 +647,15 @@ export async function verifySmartWalletTransaction( if (matchingTransfers.length === 0) { if (allTransfers.length === 0) { - return { isValid: false, invalidReason: "smart_wallet_no_transfer_in_simulation", payer: "" }; + return { + isValid: false, + invalidReason: Errors.ErrSmartWalletNoTransferInSimulation, + payer: "", + }; } return { isValid: false, - invalidReason: "smart_wallet_transfer_mismatch", + invalidReason: Errors.ErrSmartWalletTransferMismatch, payer: allTransfers[0].authority, }; } @@ -489,12 +663,16 @@ export async function verifySmartWalletTransaction( if (matchingTransfers.length > 1) { return { isValid: false, - invalidReason: "smart_wallet_multiple_matching_transfers", + invalidReason: Errors.ErrSmartWalletMultipleMatchingTransfers, payer: matchingTransfers[0].authority, }; } - return { isValid: true, payer: matchingTransfers[0].authority }; + return { + isValid: true, + payer: matchingTransfers[0].authority, + matchedTransfer: matchingTransfers[0], + }; } /** @@ -514,6 +692,9 @@ export async function verifySmartWalletTransaction( * @param signerAddresses - Facilitator signer addresses * @param balanceBefore - Destination ATA balance before settlement (for fallback) * @param balanceBeforeTokenProgram - Which token program the balanceBefore was captured from (SPL Token or Token-2022) + * @param knownDestinationAta - Destination ATA already identified at verify time. + * When set, both the inner-instruction match and the balance-delta fallback + * query this single account instead of deriving both token-program ATAs. * @returns Whether the transfer was verified on-chain */ export async function verifyPostSettlement( @@ -524,6 +705,7 @@ export async function verifyPostSettlement( signerAddresses: string[], balanceBefore: bigint | null, balanceBeforeTokenProgram?: string | null, + knownDestinationAta?: string | null, ): Promise<{ verified: boolean; method: "innerInstructions" | "balanceDelta" | "unverified" }> { const requiredAmount = BigInt(requirements.amount); @@ -552,18 +734,22 @@ export async function verifyPostSettlement( // (programId as string, not index), so index-based resolution isn't needed. const transfers = extractTransfersFromInnerInstructions(confirmed.innerInstructions, []); - // Derive expected destination ATAs (same logic as in verifySmartWalletTransaction). const expectedATAs = new Set(); - for (const tokenProgram of [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]) { - try { - const [ata] = await findAssociatedTokenPda({ - mint: requirements.asset as Address, - owner: requirements.payTo as Address, - tokenProgram: tokenProgram as unknown as Address, - }); - expectedATAs.add(ata.toString()); - } catch { - // Skip invalid address combinations + if (knownDestinationAta) { + expectedATAs.add(knownDestinationAta); + } else { + // Derive expected destination ATAs (same logic as in verifySmartWalletTransaction). + for (const tokenProgram of [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]) { + try { + const [ata] = await findAssociatedTokenPda({ + mint: requirements.asset as Address, + owner: requirements.payTo as Address, + tokenProgram: tokenProgram as unknown as Address, + }); + expectedATAs.add(ata.toString()); + } catch { + // Skip invalid address combinations + } } } @@ -593,6 +779,21 @@ export async function verifyPostSettlement( // check whether the destination ATA balance increased by at least the required amount. // Try both SPL Token and Token-2022 programs — the payment may use either. if (balanceBefore !== null && typeof signer.getTokenAccountBalance === "function") { + if (knownDestinationAta) { + try { + const balanceAfter = await signer.getTokenAccountBalance(knownDestinationAta, network); + if (balanceAfter !== null) { + if (balanceAfter - balanceBefore >= requiredAmount) { + return { verified: true, method: "balanceDelta" }; + } + return { verified: false, method: "balanceDelta" }; + } + } catch { + // Known ATA balance check failed — fall through to unverified. + } + return { verified: false, method: "unverified" }; + } + // If we know which token program was used for balanceBefore, check that one first. // Otherwise try both (SPL Token first, then Token-2022). const tokenProgramsToCheck = balanceBeforeTokenProgram diff --git a/typescript/packages/mechanisms/svm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/server/scheme.ts index cec867a36d..a73220af60 100644 --- a/typescript/packages/mechanisms/svm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/server/scheme.ts @@ -28,7 +28,7 @@ export class ExactSvmScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; readonly dynamicExtraFields = ["recentBlockhash", "lastValidBlockHeight"]; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts index b2c8f3c753..d17db6e7e7 100644 --- a/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts @@ -38,6 +38,9 @@ import { getTokenPayerFromTransaction, transactionMessageHash, } from "../../../utils"; +import { verifyRequiredSignatures } from "../../facilitator/signatureVerification"; + +const compiledMessageDecoder = getCompiledTransactionMessageDecoder(); const IX_TOKEN_TRANSFER_CHECKED = 12; @@ -155,8 +158,42 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { }; } - const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); - const decompiled = decompileTransactionMessage(compiled); + let compiled; + try { + compiled = compiledMessageDecoder.decode(transaction.messageBytes); + } catch { + return { + isValid: false, + invalidReason: "invalid_exact_svm_payload_transaction_could_not_be_decoded", + payer: "", + }; + } + + const signatureCheck = await verifyRequiredSignatures( + transaction, + compiled, + requirementsV1.extra.feePayer, + ); + if (!signatureCheck.ok) { + return { + isValid: false, + invalidReason: signatureCheck.invalidReason, + payer: "", + }; + } + + let decompiled; + try { + decompiled = decompileTransactionMessage(compiled); + } catch { + // v1 has no ALT resolution; reject lookup-table transactions cleanly + // rather than throwing out of verify(). + return { + isValid: false, + invalidReason: "invalid_exact_svm_payload_transaction_could_not_be_decoded", + payer: "", + }; + } const instructions = decompiled.instructions ?? []; // Allow 3-6 instructions: @@ -344,20 +381,12 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { } } - // Step 6: Sign and Simulate Transaction + // Step 6: Simulate Transaction // CRITICAL: Simulation proves transaction will succeed (catches insufficient balance, invalid accounts, etc) + // Signatures are verified locally; the fee-payer slot is unsigned until settle. try { - const feePayer = requirementsV1.extra.feePayer as Address; - - // Sign transaction with the feePayer's signer - const fullySignedTransaction = await this.signer.signTransaction( - exactSvmPayload.transaction, - feePayer, - requirements.network, - ); - // Simulate to verify transaction would succeed - await this.signer.simulateTransaction(fullySignedTransaction, requirements.network); + await this.signer.simulateTransaction(exactSvmPayload.transaction, requirements.network); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { @@ -390,34 +419,51 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { const payloadV1 = payload as unknown as PaymentPayloadV1; const exactSvmPayload = payload.payload as ExactSvmPayloadV1; - const valid = await this.verify(payload, requirements); - if (!valid.isValid) { + // Decode the transaction to compute the message hash used as the cache key. + // Must remain synchronous (before any await) so concurrent settle calls for + // the same payment are caught before any async work begins. + let txKey: string | undefined; + let decodedTx: ReturnType | undefined; + try { + decodedTx = decodeTransactionFromPayload(exactSvmPayload); + txKey = transactionMessageHash(decodedTx); + } catch { + txKey = undefined; + } + + // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). + if (txKey && this.settlementCache.isDuplicate(txKey)) { + let payer = ""; + try { + payer = getTokenPayerFromTransaction(decodedTx!) || ""; + } catch { + payer = ""; + } return { success: false, network: payloadV1.network, transaction: "", - errorReason: valid.invalidReason ?? "verification_failed", - payer: valid.payer || "", + errorReason: "duplicate_settlement", + payer, }; } - // Decode the transaction to compute the message hash used as the cache key. - // Must remain synchronous (before any await) so concurrent settle calls for - // the same payment are caught before any async work begins. - const decodedTx = decodeTransactionFromPayload(exactSvmPayload); - - // Duplicate settlement check keyed on message hash (immune to mutable fee-payer sig at slot 0). - const txKey = transactionMessageHash(decodedTx); - if (this.settlementCache.isDuplicate(txKey)) { + const valid = await this.verify(payload, requirements); + if (!valid.isValid) { + if (txKey) { + this.settlementCache.delete(txKey); + } return { success: false, network: payloadV1.network, transaction: "", - errorReason: "duplicate_settlement", + errorReason: valid.invalidReason ?? "verification_failed", payer: valid.payer || "", }; } + txKey ??= transactionMessageHash(decodeTransactionFromPayload(exactSvmPayload)); + try { // Extract feePayer from requirements (already validated in verify) const feePayer = requirements.extra.feePayer as Address; diff --git a/typescript/packages/mechanisms/svm/src/index.ts b/typescript/packages/mechanisms/svm/src/index.ts index 8c55ddd937..db3ddcf7e3 100644 --- a/typescript/packages/mechanisms/svm/src/index.ts +++ b/typescript/packages/mechanisms/svm/src/index.ts @@ -29,6 +29,9 @@ export { toClientSvmSigner, toFacilitatorSvmSigner } from "./signer"; export type { ClientSvmSigner, FacilitatorSvmSigner, + FacilitatorAccountInfo, + FacilitatorProgramAccount, + FacilitatorSimulateTransactionOptions, FacilitatorRpcClient, FacilitatorRpcConfig, ClientSvmConfig, diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts b/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts index 7f9a5a513f..95f199198c 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts @@ -6,9 +6,9 @@ * distribution recipient that Open/Sealed actions need. */ -import { address, type Address, type Base58EncodedBytes } from "@solana/kit"; +import { address, type Address, type Base58EncodedBytes, getBase64Encoder } from "@solana/kit"; -import type { ChannelRpc } from "../upto/facilitator/channel"; +import type { FacilitatorSvmSigner } from "../signer"; import type { Channel } from "./generated/accounts/channel"; import { getChannelDecoder } from "./generated/accounts/channel"; import { PAYMENT_CHANNELS_PROGRAM_ID } from "./onchain"; @@ -35,33 +35,39 @@ export interface DiscoveredChannel { * by `getProgramAccounts` itself, which only lists accounts owned by the * program passed to it. * - * @param rpc - RPC client + * @param signer - Facilitator signer with {@link FacilitatorSvmSigner.getProgramAccounts} + * @param network - CAIP-2 network identifier * @param rentPayer - Facilitator key to discover channels for (base58) * @param programId - Optional payment-channels program id override * @returns Validated discovered channels */ export async function discoverChannelsByRentPayer( - rpc: ChannelRpc, + signer: Pick, + network: string, rentPayer: string, programId?: string, ): Promise { + if (typeof signer.getProgramAccounts !== "function") { + throw new Error( + "discoverChannelsByRentPayer requires getProgramAccounts on the signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } const program = address(programId ?? PAYMENT_CHANNELS_PROGRAM_ID); - const results = await rpc - .getProgramAccounts(program, { - commitment: "confirmed", - encoding: "base64", - filters: [ - { dataSize: CHANNEL_ACCOUNT_SIZE }, - { - memcmp: { - bytes: rentPayer as Base58EncodedBytes, - encoding: "base58", - offset: CHANNEL_RENT_PAYER_OFFSET, - }, + const results = await signer.getProgramAccounts(network, program.toString(), { + commitment: "confirmed", + encoding: "base64", + filters: [ + { dataSize: CHANNEL_ACCOUNT_SIZE }, + { + memcmp: { + bytes: rentPayer as Base58EncodedBytes, + encoding: "base58", + offset: CHANNEL_RENT_PAYER_OFFSET, }, - ], - }) - .send(); + }, + ], + }); const discovered: DiscoveredChannel[] = []; for (const result of results) { @@ -96,7 +102,7 @@ async function validateDiscoveredAccount( ): Promise { if (owner !== expectedProgram) return undefined; - const bytes = Buffer.from(base64Data, "base64"); + const bytes = getBase64Encoder().encode(base64Data); if (bytes.byteLength < Number(CHANNEL_ACCOUNT_SIZE)) return undefined; let channel: Channel; diff --git a/typescript/packages/mechanisms/svm/src/signer.ts b/typescript/packages/mechanisms/svm/src/signer.ts index 7165ba0b9e..4a4b2c02f7 100644 --- a/typescript/packages/mechanisms/svm/src/signer.ts +++ b/typescript/packages/mechanisms/svm/src/signer.ts @@ -10,7 +10,12 @@ import type { Address, } from "@solana/kit"; import { fetchAddressesForLookupTables, getBase64EncodedWireTransaction } from "@solana/kit"; -import { createRpcClient, decodeTransactionFromPayload } from "./utils"; +import { + createRpcClient, + decodeTransactionFromPayload, + TransactionOnchainFailureError, +} from "./utils"; +import { ErrSmartWalletAltResolutionFailed } from "./exact/facilitator/errors"; /** * Client-side signer for creating and signing Solana transactions @@ -106,6 +111,24 @@ export type FacilitatorRpcCapabilities = { fetchMint(address: string): Promise; }; +/** Options passed to {@link FacilitatorSvmSigner.simulateTransaction}. */ +export type FacilitatorSimulateTransactionOptions = { + /** + * When true, the RPC verifies signatures during simulation. Defaults to false + * because the fee-payer slot is often unsigned until settle. + */ + sigVerify?: boolean; + /** + * When true, the RPC substitutes a fresh blockhash before simulating (for + * facilitator-built messages compiled against a placeholder). Defaults to false. + */ + replaceRecentBlockhash?: boolean; + /** Simulation commitment. Defaults to `"confirmed"`. */ + commitment?: string; + /** Wire encoding of the transaction. Defaults to `"base64"`. */ + encoding?: string; +}; + /** * Minimal facilitator signer interface for SVM operations. * Supports multiple signers for load balancing and high availability. @@ -144,14 +167,20 @@ export type FacilitatorSvmSigner = { signTransaction(transaction: string, feePayer: Address, network: string): Promise; /** - * Simulate a signed transaction to verify it would succeed - * Implementation manages RPC client selection and simulation details + * Simulate a transaction to verify it would succeed onchain. + * By default does not verify signatures (RPC `sigVerify` is off). Callers must + * verify required signatures themselves; the fee-payer slot may be unsigned. * - * @param transaction - Base64 encoded signed transaction + * @param transaction - Base64 encoded transaction (may be partially signed) * @param network - CAIP-2 network identifier + * @param options - Optional simulation overrides * @throws Error if simulation fails */ - simulateTransaction(transaction: string, network: string): Promise; + simulateTransaction( + transaction: string, + network: string, + options?: FacilitatorSimulateTransactionOptions, + ): Promise; /** * Send a signed transaction to the network @@ -171,7 +200,10 @@ export type FacilitatorSvmSigner = { * @param signature - Transaction signature to confirm * @param network - CAIP-2 network identifier * @returns Promise that resolves when transaction is confirmed - * @throws Error if confirmation fails or times out + * @throws {TransactionOnchainFailureError} If the transaction reached the chain and failed + * there (terminal — safe to release dedup/pending-settlement locks) + * @throws Error for any other confirmation failure, e.g. a wait timeout (non-terminal — + * the outcome is unknown, so callers must not treat it as a definite failure) */ confirmTransaction(signature: string, network: string): Promise; @@ -184,14 +216,12 @@ export type FacilitatorSvmSigner = { * The default toFacilitatorSvmSigner() factory provides an implementation. * * @param transaction - Base64 encoded transaction (may be partially signed) - * @param feePayer - Fee payer address to sign with before simulation * @param network - CAIP-2 network identifier * @returns Inner instructions from simulation, or null if unavailable * @throws Error if simulation fails (transaction would revert on-chain) */ simulateTransactionWithInnerInstructions?( transaction: string, - feePayer: Address, network: string, ): Promise; @@ -241,6 +271,61 @@ export type FacilitatorSvmSigner = { lookupTableAddresses: string[], network: string, ): Promise>; + + /** + * Fetch one account's onchain data. Optional — required by the `upto` scheme; + * {@link toFacilitatorSvmSigner} provides an implementation. + */ + getAccountInfo?( + accountAddress: string, + network: string, + options?: { commitment?: string; encoding?: string }, + ): Promise; + + /** + * Fetch a recent blockhash. Optional — required by the `upto` scheme; + * {@link toFacilitatorSvmSigner} provides an implementation. + */ + getLatestBlockhash?(network: string): Promise<{ + blockhash: string; + lastValidBlockHeight: bigint; + }>; + + /** + * Fetch the current slot. Optional — required by the `upto` scheme; + * {@link toFacilitatorSvmSigner} provides an implementation. + */ + getSlot?(network: string, commitment?: string): Promise; + + /** + * Scan program accounts. Optional — required only for `upto` rent-cleanup + * discovery sweeps; {@link toFacilitatorSvmSigner} provides an implementation. + */ + getProgramAccounts?( + network: string, + programId: string, + config: { + commitment?: string; + encoding?: string; + filters?: readonly unknown[]; + }, + ): Promise; +}; + +/** Account info returned by {@link FacilitatorSvmSigner.getAccountInfo}. */ +export type FacilitatorAccountInfo = { + data: [string, string] | string; + owner: string; + lamports: bigint; +}; + +/** One row from {@link FacilitatorSvmSigner.getProgramAccounts}. */ +export type FacilitatorProgramAccount = { + pubkey: Address; + account: { + data: [string, string]; + owner: Address; + }; }; /** @@ -311,15 +396,21 @@ export function createRpcCapabilitiesFromRpc( return await rpc .sendTransaction(transaction as never, { encoding: "base64", + skipPreflight: true, + preflightCommitment: "confirmed", }) .send(); }, confirmTransaction: async signature => { - let confirmed = false; - let attempts = 0; - const maxAttempts = 30; - - while (!confirmed && attempts < maxAttempts) { + // Poll at 250ms for the first ~2s (Solana slots are ~400ms), then 1s, + // keeping the same ~30s confirmation budget as the previous 30×1s loop. + const initialDelayMs = 250; + const initialWindowMs = 2_000; + const fallbackDelayMs = 1_000; + const maxWaitMs = 30_000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < maxWaitMs) { const status = await rpc.getSignatureStatuses([signature as never]).send(); const entry = status.value[0]; @@ -331,14 +422,14 @@ export function createRpcCapabilitiesFromRpc( const errorStr = JSON.stringify(entry.err, (_, v) => typeof v === "bigint" ? v.toString() : v, ); - throw new Error(`Transaction failed onchain: ${errorStr}`); + throw new TransactionOnchainFailureError(`Transaction failed onchain: ${errorStr}`); } - confirmed = true; return entry; } - await new Promise(resolve => setTimeout(resolve, 1000)); - attempts++; + const elapsed = Date.now() - startedAt; + const delay = elapsed < initialWindowMs ? initialDelayMs : fallbackDelayMs; + await new Promise(resolve => setTimeout(resolve, delay)); } throw new Error("Transaction confirmation timeout"); @@ -469,15 +560,22 @@ export function toFacilitatorSvmSigner( return getBase64EncodedWireTransaction(fullySignedTx); }, - simulateTransaction: async (transaction: string, network: string) => { + simulateTransaction: async ( + transaction: string, + network: string, + options?: FacilitatorSimulateTransactionOptions, + ) => { const rpc = getRpcForNetwork(network); const result = await rpc - .simulateTransaction(transaction as never, { - sigVerify: true, - replaceRecentBlockhash: false, - commitment: "confirmed", - encoding: "base64", - }) + .simulateTransaction( + transaction as never, + { + sigVerify: options?.sigVerify ?? false, + replaceRecentBlockhash: options?.replaceRecentBlockhash ?? false, + commitment: options?.commitment ?? "confirmed", + encoding: options?.encoding ?? "base64", + } as never, + ) .send(); if (result.value.err) { @@ -494,6 +592,8 @@ export function toFacilitatorSvmSigner( return await rpc .sendTransaction(transaction as never, { encoding: "base64", + skipPreflight: true, + preflightCommitment: "confirmed", }) .send(); }, @@ -504,34 +604,13 @@ export function toFacilitatorSvmSigner( await rpcCapabilities.confirmTransaction(signature); }, - simulateTransactionWithInnerInstructions: async ( - transaction: string, - feePayer: Address, - network: string, - ) => { - if (feePayer !== signer.address) { - throw new Error(`No signer for feePayer ${feePayer}. Available: ${signer.address}`); - } - - const tx = decodeTransactionFromPayload({ transaction }); - const signableMessage = { - content: tx.messageBytes, - signatures: tx.signatures, - }; - const [facilitatorSignatureDictionary] = await signer.signMessages([ - signableMessage as never, - ]); - const signedTx = { - ...tx, - signatures: { ...tx.signatures, ...facilitatorSignatureDictionary }, - }; - const signedBase64 = getBase64EncodedWireTransaction(signedTx); - + simulateTransactionWithInnerInstructions: async (transaction: string, network: string) => { // Signature and blockhash verification during simulation. // - // sigVerify: true — matches the existing Path 1 simulation behavior. Ensures all - // transaction signatures are valid, preventing an attacker from passing verify() - // with forged signatures and getting free resource access before on-chain failure. + // sigVerify: false — required signatures are verified locally before this + // call. The fee-payer slot is unsigned until settle, so RPC-side + // sigVerify would reject a valid payment. Account state, fee-payer + // balance, and precompiles are still evaluated by simulation. // // replaceRecentBlockhash: false — preserves the original blockhash so signatures // remain valid (they cover the full message including blockhash). Also required for @@ -546,9 +625,9 @@ export function toFacilitatorSvmSigner( const rpc = getRpcForNetwork(network); const result = await rpc .simulateTransaction( - signedBase64 as never, + transaction as never, { - sigVerify: true, + sigVerify: false, replaceRecentBlockhash: false, commitment: "confirmed", encoding: "base64", @@ -644,9 +723,46 @@ export function toFacilitatorSvmSigner( // accounts. The caller (assertFeePayerIsolated) converts this into a // verify failure so a transient RPC blip fails the payment safely. throw new Error( - `smart_wallet_alt_resolution_failed: ${error instanceof Error ? error.message : String(error)}`, + `${ErrSmartWalletAltResolutionFailed}: ${error instanceof Error ? error.message : String(error)}`, ); } }, + + getAccountInfo: async (accountAddress, network, options) => { + const rpc = getRpcForNetwork(network); + const result = await rpc + .getAccountInfo(accountAddress as never, { + commitment: (options?.commitment ?? "confirmed") as never, + encoding: (options?.encoding ?? "base64") as never, + }) + .send(); + const value = result.value as FacilitatorAccountInfo | null; + return value; + }, + + getLatestBlockhash: async (network: string) => { + const rpc = getRpcForNetwork(network); + const result = await rpc.getLatestBlockhash({ commitment: "finalized" }).send(); + return { + blockhash: result.value.blockhash, + lastValidBlockHeight: result.value.lastValidBlockHeight, + }; + }, + + getSlot: async (network: string, commitment = "finalized") => { + const rpc = getRpcForNetwork(network); + return await rpc.getSlot({ commitment: commitment as never }).send(); + }, + + getProgramAccounts: async (network, programId, config) => { + const rpc = getRpcForNetwork(network); + return await rpc + .getProgramAccounts(programId as Address, { + commitment: (config.commitment ?? "confirmed") as never, + encoding: "base64", + filters: config.filters as never, + }) + .send(); + }, }; } diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts index 8345044c22..ac62c1bb9e 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts @@ -7,7 +7,7 @@ * readable. All RPC access is threaded in by the caller. */ -import { createHash } from "node:crypto"; +import { sha256 } from "@noble/hashes/sha256"; import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction, @@ -50,8 +50,9 @@ import { } from "../../payment-channels/onchain"; import type { ChannelSplit } from "../../payment-channels/open"; import type { FacilitatorSvmSigner } from "../../signer"; -import { createRpcClient } from "../../utils"; -import { BLOCKHASH_COMMITMENT, STATE_COMMITMENT } from "../shared"; +import { TransactionOnchainFailureError } from "../../utils"; +import { STATE_COMMITMENT } from "../shared"; +import type { UptoFacilitatorSigner } from "./signer"; /** Payment-channels `AccountDiscriminator::Channel` (byte 0 is reserved for uninitialized accounts). */ const CHANNEL_ACCOUNT_DISCRIMINATOR = 1; @@ -105,21 +106,54 @@ export function reclaimComputeUnitLimit(channelCount: number): number { /** Signer capable of signing Solana transactions and raw Ed25519 messages. */ export type UptoSvmSigner = TransactionSigner & MessagePartialSigner; -/** RPC client shape used by the channel helpers. */ -export type ChannelRpc = ReturnType; +/** Placeholder blockhash for deposit composite sims (`replaceRecentBlockhash: true`). */ +const SIM_PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111" as Blockhash; + +/** + * Kit-compatible RPC adapter for generated account fetch helpers. + * + * @param signer - Upto facilitator signer + * @param network - CAIP-2 network identifier + * @returns Minimal RPC surface for {@link fetchMaybeChannel} + */ +export function accountFetchRpc( + signer: UptoFacilitatorSigner, + network: string, +): Parameters[0] { + return { + getAccountInfo: ( + accountAddress: Address, + config?: { commitment?: string; encoding?: string }, + ) => ({ + send: async () => ({ + context: { slot: 0n }, + value: await signer.getAccountInfo(accountAddress.toString(), network, { + commitment: config?.commitment, + encoding: config?.encoding, + }), + }), + }), + } as Parameters[0]; +} /** * Whether the channel account already exists onchain (open already broadcast). * - * @param rpc - The RPC client + * @param signer - Facilitator signer with read RPC + * @param network - CAIP-2 network identifier * @param channelId - Channel PDA (base58) * @returns Whether the account exists */ -export async function channelExists(rpc: ChannelRpc, channelId: string): Promise { - const info = await rpc - .getAccountInfo(address(channelId), { commitment: STATE_COMMITMENT, encoding: "base64" }) - .send(); - return info.value !== null; +export async function channelExists( + signer: UptoFacilitatorSigner, + network: string, + channelId: string, +): Promise { + const info = await signer.getAccountInfo(channelId, network, { + commitment: STATE_COMMITMENT, + encoding: "base64", + }); + return info !== null; } /** Challenge-bound terms that must match the confirmed channel account. */ @@ -150,16 +184,19 @@ export interface VerifiedOpenChannel { /** * Fetch and bind the confirmed channel account before resource execution. * - * @param rpc - RPC client used to read the channel + * @param signer - Facilitator signer with read RPC + * @param network - CAIP-2 network identifier * @param channelId - Channel PDA * @param expected - Challenge-bound channel terms * @returns Verified channel facts for settlement */ export async function fetchAndVerifyOpenChannel( - rpc: ChannelRpc, + signer: UptoFacilitatorSigner, + network: string, channelId: string, expected: ExpectedOpenChannel, ): Promise { + const rpc = accountFetchRpc(signer, network); for (let attempt = 1; attempt <= CHANNEL_READ_MAX_ATTEMPTS; attempt++) { const account = await fetchMaybeChannel(rpc, address(channelId), { commitment: STATE_COMMITMENT, @@ -236,6 +273,34 @@ export function verifyOpenChannelAccount( }; } +/** + * Thrown by {@link broadcastOpen} when the open transaction broadcast + * successfully but `confirmTransaction`'s wait timed out (outcome still + * unknown — a definite onchain failure propagates as + * {@link TransactionOnchainFailureError} instead). Distinct from a sign/send + * failure (nothing reached the chain, safe to retry): this carries the + * broadcast `signature` so the caller can reconcile against it instead of + * re-broadcasting (a second open would hit the channel-already-open check + * even though the original open is, or will be, fine). + */ +export class ChannelOpenConfirmationError extends Error { + /** + * Create the error for an open broadcast whose confirmation failed. + * + * @param signature - The broadcast signature whose confirmation failed + * @param cause - The underlying confirmation error + */ + constructor( + readonly signature: string, + cause: unknown, + ) { + super( + `failed to confirm channel open ${signature}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + this.name = "ChannelOpenConfirmationError"; + } +} + /** * Co-sign the fee-payer slot of a partially-signed open transaction, * broadcast it, and wait for confirmation. No-op skip is the caller's job @@ -243,11 +308,20 @@ export function verifyOpenChannelAccount( * * Uses the wire-level FacilitatorSvmSigner methods (same path as exact). * + * On a confirmation failure the broadcast signature is still surfaced (via + * {@link ChannelOpenConfirmationError}), unlike a sign/send failure — so the + * caller can distinguish "never landed, safe to retry with a fresh + * broadcast" from "broadcast successfully but unconfirmed, must reconcile + * against this signature instead of re-broadcasting." + * * @param facilitator - Facilitator signer with wire sign/send/confirm * @param feePayer - Fee-payer address to co-sign with * @param network - CAIP-2 network identifier * @param openTransactionBase64 - The client-signed open transaction * @returns The broadcast signature + * @throws {ChannelOpenConfirmationError} If the transaction broadcast but confirmation timed out + * @throws {TransactionOnchainFailureError} If the transaction broadcast but failed onchain + * (terminal — the caller can safely retry with a fresh open) */ export async function broadcastOpen( facilitator: Pick< @@ -260,7 +334,18 @@ export async function broadcastOpen( ): Promise { const wire = await facilitator.signTransaction(openTransactionBase64, feePayer, network); const signature = await facilitator.sendTransaction(wire, network); - await facilitator.confirmTransaction(signature, network); + try { + await facilitator.confirmTransaction(signature, network); + } catch (error) { + // A definite onchain rejection is terminal, unlike a confirmation + // timeout: propagate it as-is (rather than wrapping in + // ChannelOpenConfirmationError) so the caller's non-pending branch + // handles it — a fresh open is safe to retry. + if (error instanceof TransactionOnchainFailureError) { + throw error; + } + throw new ChannelOpenConfirmationError(signature, error); + } return signature; } @@ -288,14 +373,16 @@ export interface SettlementSimChannel { * `sigVerify: false`. * * @param feePayer - The fee-payer / channel payee signer - * @param rpc - The RPC client + * @param signer - Facilitator signer (simulate RPC) + * @param network - CAIP-2 network identifier * @param args - Open transaction and challenge-bound channel terms * @param args.openTransactionBase64 - Client-signed open transaction * @param args.channel - Challenge-bound channel terms for settle/distribute */ export async function simulateOpenSettleDistribute( feePayer: UptoSvmSigner, - rpc: ChannelRpc, + signer: Pick, + network: string, args: { openTransactionBase64: string; channel: SettlementSimChannel; @@ -352,7 +439,7 @@ export async function simulateOpenSettleDistribute( distribute, ]; - await simulateInstructions(feePayer, rpc, instructions); + await simulateInstructions(feePayer, signer, network, instructions); } /** Options for {@link submitSettle}. */ @@ -370,12 +457,34 @@ export interface SubmitSettleOptions { * `DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS` (1). */ computeUnitPriceMicroLamports?: number | undefined; + /** + * Prefetched blockhash (e.g. from a parallel read in claim settle). When + * omitted, {@link submitSettle} fetches one via the signer. + */ + latestBlockhash?: { blockhash: string; lastValidBlockHeight: bigint } | undefined; +} + +/** + * Thrown by {@link submitSettle} when explicit simulation fails. The transaction + * is never broadcast. + */ +export class SettlementSimulationError extends Error { + /** + * Create the error for a settlement simulation failure. + * + * @param cause - Underlying simulation error + */ + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.name = "SettlementSimulationError"; + } } /** * Compile the settle+distribute instructions into a transaction signed by the - * fee payer, broadcast it, and confirm. Other signers, such as the channel - * payee on `settle_and_seal`, are carried by the instruction list. + * fee payer, simulate it, broadcast via the facilitator signer, and confirm. + * Other signers, such as the channel payee on `settle_and_seal`, are carried + * by the instruction list. * * The transaction is prefixed with a statically sized `SetComputeUnitLimit` * and an optional `SetComputeUnitPrice`. Static sizing keeps the time-critical @@ -383,14 +492,19 @@ export interface SubmitSettleOptions { * operator-overridable for deployments outside the documented assumptions. * * @param feePayer - The fee-payer signer - * @param rpc - The RPC client + * @param signer - Facilitator signer (blockhash, simulate, send, confirm) + * @param network - CAIP-2 network identifier * @param instructions - settle_and_seal (+ optional Ed25519 precompile) then distribute * @param options - Compute-budget options * @returns The broadcast signature */ export async function submitSettle( feePayer: UptoSvmSigner, - rpc: ChannelRpc, + signer: Pick< + UptoFacilitatorSigner, + "getLatestBlockhash" | "simulateTransaction" | "sendTransaction" | "confirmTransaction" + >, + network: string, instructions: readonly ServerInstruction[], options: SubmitSettleOptions = {}, ): Promise { @@ -404,9 +518,7 @@ export async function submitSettle( : []), ]; - const { value: latestBlockhash } = await rpc - .getLatestBlockhash({ commitment: BLOCKHASH_COMMITMENT }) - .send(); + const latestBlockhash = options.latestBlockhash ?? (await signer.getLatestBlockhash(network)); const message = pipe( createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayerSigner(feePayer, m), @@ -422,17 +534,26 @@ export async function submitSettle( ); const signed = await signTransactionMessageWithSigners(message); const wire = getBase64EncodedWireTransaction(signed); - const signature = await rpc.sendTransaction(wire, { encoding: "base64" }).send(); - await confirmSignature(rpc, signature); - return signature; + try { + await signer.simulateTransaction(wire, network); + } catch (error) { + throw new SettlementSimulationError(error); + } + const signature = await signer.sendTransaction(wire, network); + try { + await signer.confirmTransaction(signature, network); + } catch (error) { + if (error instanceof TransactionOnchainFailureError) { + throw error; + } + throw new SettlementConfirmationTimeoutError(signature as Signature); + } + return signature as Signature; } /** - * Thrown by {@link confirmSignature} when the polling budget elapses before - * the transaction reaches `confirmed`. Distinct from an onchain rejection: - * the transaction's fate is unknown, not failed — it may still land. Callers - * must not treat this the same as a definite failure (e.g. must not assume - * it is safe to retry the same settlement). + * Thrown by {@link submitSettle} when confirmation polling times out. Distinct + * from an onchain rejection: the transaction's fate is unknown, not failed. */ export class SettlementConfirmationTimeoutError extends Error { /** @@ -446,43 +567,6 @@ export class SettlementConfirmationTimeoutError extends Error { } } -/** - * Poll `getSignatureStatuses` until the signature reaches at least 'confirmed'. - * - * @param rpc - The RPC client - * @param signature - The transaction signature - * @param timeoutMs - Total time budget (default 30s) - * @throws {SettlementConfirmationTimeoutError} If the timeout elapses with the outcome still unknown - * @throws If the transaction failed onchain - */ -export async function confirmSignature( - rpc: ChannelRpc, - signature: Signature, - timeoutMs = 30_000, -): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - const { value } = await rpc.getSignatureStatuses([signature]).send(); - const status = value[0]; - if (status) { - if (status.err) { - const errorStr = JSON.stringify(status.err, (_, v) => - typeof v === "bigint" ? v.toString() : v, - ); - throw new Error(`tx ${signature} failed onchain: ${errorStr}`); - } - const level = status.confirmationStatus; - if (level === undefined || level === null || level === "confirmed" || level === "finalized") { - return; - } - } - if (Date.now() >= deadline) { - throw new SettlementConfirmationTimeoutError(signature); - } - await new Promise(resolve => setTimeout(resolve, 1_000)); - } -} - /** * Assert one decoded channel address matches its challenge-bound value. * @@ -503,7 +587,7 @@ function assertChannelAddress(label: string, actual: string, expected: string): * @returns SHA-256 of the program's canonical distribution preimage */ export function getChannelDistributionHash(splits: readonly ChannelSplit[]): Uint8Array { - const hasher = createHash("sha256"); + const hasher = sha256.create(); const count = new Uint8Array(4); new DataView(count.buffer).setUint32(0, splits.length, true); hasher.update(count); @@ -524,25 +608,24 @@ export function getChannelDistributionHash(splits: readonly ChannelSplit[]): Uin * open composite may carry a noop payer) and `replaceRecentBlockhash: true`. * * @param feePayer - The fee-payer signer - * @param rpc - The RPC client + * @param signer - Facilitator signer (simulate RPC) + * @param network - CAIP-2 network identifier * @param instructions - Instructions to simulate */ async function simulateInstructions( feePayer: UptoSvmSigner, - rpc: ChannelRpc, + signer: Pick, + network: string, instructions: readonly Instruction[], ): Promise { - const { value: latestBlockhash } = await rpc - .getLatestBlockhash({ commitment: BLOCKHASH_COMMITMENT }) - .send(); const message = pipe( createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayerSigner(feePayer, m), m => setTransactionMessageLifetimeUsingBlockhash( { - blockhash: latestBlockhash.blockhash as Blockhash, - lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + blockhash: SIM_PLACEHOLDER_BLOCKHASH, + lastValidBlockHeight: 0n, }, m, ), @@ -550,18 +633,11 @@ async function simulateInstructions( ); const signed = await partiallySignTransactionMessageWithSigners(message); const wire = getBase64EncodedWireTransaction(signed); - const result = await rpc - .simulateTransaction(wire, { - commitment: STATE_COMMITMENT, - encoding: "base64", - replaceRecentBlockhash: true, - sigVerify: false, - }) - .send(); - if (result.value.err) { - const errorStr = JSON.stringify(result.value.err, (_, v) => - typeof v === "bigint" ? v.toString() : v, + try { + await signer.simulateTransaction(wire, network, { replaceRecentBlockhash: true }); + } catch (error) { + throw new Error( + `zero-charge settlement simulation failed: ${error instanceof Error ? error.message : String(error)}`, ); - throw new Error(`zero-charge settlement simulation failed: ${errorStr}`); } } diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts index fc7be04dc7..c35ad6c438 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts @@ -3,13 +3,17 @@ export { ERR_CHANNEL_ALREADY_OPEN, ERR_CHANNEL_LIFETIME_EXCEEDED, ERR_EXPIRES_AT_MISMATCH, - ERR_SETTLEMENT_CONFIRMATION_TIMEOUT, ERR_SETTLEMENT_EXCEEDS_AMOUNT, ERR_UNEXPECTED_VOUCHER, UptoSvmScheme, } from "./scheme"; export type { UptoChannelStorageErrorContext, UptoSvmFacilitatorConfig } from "./scheme"; -export { SettlementConfirmationTimeoutError } from "./channel"; +export { ErrSettlementPending } from "../../exact/facilitator/errors"; +export { + ChannelOpenConfirmationError, + SettlementConfirmationTimeoutError, + SettlementSimulationError, +} from "./channel"; export type { UptoSvmSigner } from "./channel"; export { InMemoryUptoChannelStorage } from "./channelStorage"; export type { UptoChannelRecord, UptoChannelStorage } from "./channelStorage"; diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts index 556eb3e917..1e63641b3b 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts @@ -28,11 +28,11 @@ import { } from "../../payment-channels/onchain"; import { OPEN_SLOT_WINDOW } from "../../payment-channels/open"; import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../../signer"; -import { createRpcClient } from "../../utils"; import { BASIS_POINTS_DENOMINATOR, SLOT_COMMITMENT, STATE_COMMITMENT } from "../shared"; -import type { ChannelRpc, UptoSvmSigner } from "./channel"; -import { reclaimComputeUnitLimit, submitSettle } from "./channel"; +import type { UptoSvmSigner } from "./channel"; +import { accountFetchRpc, reclaimComputeUnitLimit, submitSettle } from "./channel"; import type { UptoChannelRecord, UptoChannelStorage } from "./channelStorage"; +import { assertUptoFacilitatorSigner, type UptoFacilitatorSigner } from "./signer"; /** Reclaim work item: storage key plus live rent_payer from the channel account. */ interface ReclaimCandidate { @@ -240,7 +240,6 @@ export interface UptoSvmRentCleanupManagerConfig { signer: FacilitatorSvmSigner; storage: UptoChannelStorage; network: Network; - rpcUrl?: string; /** * `SetComputeUnitPrice` (microlamports per compute unit) attached to cleanup * transactions; `0` omits the instruction. Defaults to @@ -255,8 +254,6 @@ export interface UptoSvmRentCleanupManagerConfig { * (`reclaimComputeUnitLimit`) and are mint-independent. */ settleComputeUnitLimit?: number; - /** Injected RPC client used instead of building one from `rpcUrl`. */ - rpc?: ChannelRpc; } /** @@ -266,14 +263,12 @@ export interface UptoSvmRentCleanupManagerConfig { * {@link cleanup}; the facilitator scheme never auto-starts this. */ export class UptoSvmRentCleanupManager { - private readonly signer: FacilitatorSvmSigner; + private readonly signer: UptoFacilitatorSigner; private readonly getKitSigner: (feePayer: Address) => FacilitatorSigningCapabilities; private readonly storage: UptoChannelStorage; private readonly network: Network; - private readonly rpcUrl: string | undefined; private readonly computeUnitPriceMicroLamports: number | undefined; private readonly settleComputeUnitLimit: number | undefined; - private readonly rpc: ChannelRpc | undefined; private timer: ReturnType | undefined; private discoveryTimer: ReturnType | undefined; @@ -311,20 +306,13 @@ export class UptoSvmRentCleanupManager { * @param config - Signer pool, channel storage, and network/RPC */ constructor(config: UptoSvmRentCleanupManagerConfig) { - if (typeof config.signer.getSigner !== "function") { - throw new Error( - "UptoSvmRentCleanupManager requires getSigner on the signer. " + - "Use toFacilitatorSvmSigner() which provides all required methods.", - ); - } + assertUptoFacilitatorSigner(config.signer, "UptoSvmRentCleanupManager"); this.getKitSigner = config.signer.getSigner.bind(config.signer); this.signer = config.signer; this.storage = config.storage; this.network = config.network; - this.rpcUrl = config.rpcUrl; this.computeUnitPriceMicroLamports = config.computeUnitPriceMicroLamports; this.settleComputeUnitLimit = config.settleComputeUnitLimit; - this.rpc = config.rpc; } /** @@ -440,7 +428,7 @@ export class UptoSvmRentCleanupManager { const maxClosesPerRun = resolveCleanupCount(opts.maxClosesPerRun, DEFAULT_MAX_CLOSES_PER_RUN); const abort = passAbort(this.abortController?.signal, opts.signal); - const rpc = this.rpc ?? createRpcClient(this.network, this.rpcUrl); + const rpc = accountFetchRpc(this.signer, this.network); const records = orderForScan(await this.storage.list(), this.scanCursor); this.scanCursor = ""; const nowSecs = Math.floor(Date.now() / 1_000); @@ -449,7 +437,7 @@ export class UptoSvmRentCleanupManager { let closesUsed = 0; const reclaimCandidates: ReclaimCandidate[] = []; const getCurrentSlot = async (): Promise => { - currentSlot ??= await rpc.getSlot({ commitment: SLOT_COMMITMENT }).send(); + currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); return currentSlot; }; @@ -510,7 +498,6 @@ export class UptoSvmRentCleanupManager { const signature = await this.submitCloseOrDistribute( feePayerSigner, - rpc, record, live, status, @@ -522,7 +509,7 @@ export class UptoSvmRentCleanupManager { transaction: signature, action: status === ChannelStatus.Open ? "abandon_close" : "distribute", }); - await this.syncStorageAfterAction(rpc, record.channelId); + await this.syncStorageAfterAction(record.channelId); continue; } @@ -548,7 +535,7 @@ export class UptoSvmRentCleanupManager { } } - await this.submitReclaimBatches(rpc, reclaimCandidates, { + await this.submitReclaimBatches(reclaimCandidates, { maxReclaimsPerTx, maxTxsPerSigner, abort, @@ -565,7 +552,12 @@ export class UptoSvmRentCleanupManager { */ private async runDiscovery(opts: RentDiscoveryOptions): Promise { const abort = passAbort(this.abortController?.signal, opts.signal); - const rpc = this.rpc ?? createRpcClient(this.network, this.rpcUrl); + if (typeof this.signer.getProgramAccounts !== "function") { + throw new Error( + "UptoSvmRentCleanupManager.discover requires getProgramAccounts on the signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } const known = new Set((await this.storage.list()).map(record => record.channelId)); const discovered: string[] = []; @@ -575,12 +567,12 @@ export class UptoSvmRentCleanupManager { abort.throwIfAborted(); let found; try { - found = await discoverChannelsByRentPayer(rpc, managed); + found = await discoverChannelsByRentPayer(this.signer, this.network, managed); } catch (error) { opts.onError?.(error); continue; } - currentSlot ??= await rpc.getSlot({ commitment: SLOT_COMMITMENT }).send(); + currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); for (const { channelId, channel } of found) { if (known.has(channelId)) continue; @@ -647,7 +639,6 @@ export class UptoSvmRentCleanupManager { * alone for Sealed. * * @param feePayerSigner - Channel feePayer / payee signer - * @param rpc - RPC client * @param record - Stored channel (must include payTo + tokenProgram) * @param live - Refetched channel account * @param status - Live status that selected this path @@ -655,7 +646,6 @@ export class UptoSvmRentCleanupManager { */ private async submitCloseOrDistribute( feePayerSigner: UptoSvmSigner, - rpc: ChannelRpc, record: UptoChannelRecord, live: Channel, status: ChannelStatus, @@ -683,7 +673,7 @@ export class UptoSvmRentCleanupManager { ] : [distribute]; - return submitSettle(feePayerSigner, rpc, instructions, { + return submitSettle(feePayerSigner, this.signer, this.network, instructions, { computeUnitLimit: this.settleComputeUnitLimit, computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, }); @@ -692,10 +682,10 @@ export class UptoSvmRentCleanupManager { /** * After a close/distribute, delete the storage entry if the PDA is gone. * - * @param rpc - RPC client * @param channelId - Channel PDA */ - private async syncStorageAfterAction(rpc: ChannelRpc, channelId: string): Promise { + private async syncStorageAfterAction(channelId: string): Promise { + const rpc = accountFetchRpc(this.signer, this.network); const maybe = await fetchMaybeChannel(rpc, address(channelId), { commitment: STATE_COMMITMENT, }); @@ -716,7 +706,6 @@ export class UptoSvmRentCleanupManager { * maxTxsPerSigner more reclaim throughput per pass, not a share of a fixed * pool. * - * @param rpc - RPC client * @param candidates - Distributed channels ready to reclaim * @param opts - Batch size, tx budget, callbacks * @param opts.maxReclaimsPerTx - Max reclaim instructions per transaction @@ -726,7 +715,6 @@ export class UptoSvmRentCleanupManager { * @param opts.onError - Optional error callback */ private async submitReclaimBatches( - rpc: ChannelRpc, candidates: ReclaimCandidate[], opts: { maxReclaimsPerTx: number; @@ -747,7 +735,7 @@ export class UptoSvmRentCleanupManager { await Promise.all( Array.from(byRentPayer.entries()).map(([rentPayer, group]) => - this.submitReclaimGroup(rpc, rentPayer, group, opts, { remaining: opts.maxTxsPerSigner }), + this.submitReclaimGroup(rentPayer, group, opts, { remaining: opts.maxTxsPerSigner }), ), ); } @@ -756,7 +744,6 @@ export class UptoSvmRentCleanupManager { * Submit one rent payer's reclaim batches sequentially, claiming a slot * from the shared budget before each attempt. * - * @param rpc - RPC client * @param rentPayer - Rent payer this group's channels share * @param group - This rent payer's reclaim candidates * @param opts - Batch size and callbacks @@ -768,7 +755,6 @@ export class UptoSvmRentCleanupManager { * @param budget.remaining - Reclaim transactions this rent payer may submit */ private async submitReclaimGroup( - rpc: ChannelRpc, rentPayer: string, group: ReclaimCandidate[], opts: { @@ -801,6 +787,7 @@ export class UptoSvmRentCleanupManager { const batch = group.slice(i, i + opts.maxReclaimsPerTx); try { + const rpc = accountFetchRpc(this.signer, this.network); // Refetch each account immediately before acting (stale → skip). const liveBatch: ReclaimCandidate[] = []; for (const candidate of batch) { @@ -825,10 +812,16 @@ export class UptoSvmRentCleanupManager { rentPayer: candidate.rentPayer, }), ); - const signature = await submitSettle(feePayerSigner, rpc, instructions, { - computeUnitLimit: reclaimComputeUnitLimit(liveBatch.length), - computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, - }); + const signature = await submitSettle( + feePayerSigner, + this.signer, + this.network, + instructions, + { + computeUnitLimit: reclaimComputeUnitLimit(liveBatch.length), + computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, + }, + ); opts.onReclaim?.({ channelIds: liveBatch.map(c => c.channelId), transaction: signature, diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts index 3bcf40e133..3efc8c0674 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts @@ -7,6 +7,10 @@ import type { SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; import { buildDistributeInstruction, @@ -18,7 +22,14 @@ import { encodeVoucherMessageBytes, verifyVoucherSignature } from "../../payment import { SettlementCache } from "../../settlement-cache"; import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../../signer"; import { isUptoSvmPayload, type UptoSvmPayloadV2 } from "../../types"; -import { createRpcClient, validateSvmAddress } from "../../utils"; +import { + decodeTransactionFromPayload, + recordPendingOrTerminal, + transactionMessageHash, + TransactionOnchainFailureError, + validateSvmAddress, +} from "../../utils"; +import { ErrSettlementPending } from "../../exact/facilitator/errors"; import { resolveTokenProgram, resolveUptoSvmMemo, @@ -29,11 +40,12 @@ import { import { broadcastOpen, channelExists, + ChannelOpenConfirmationError, fetchAndVerifyOpenChannel, SettlementConfirmationTimeoutError, + SettlementSimulationError, simulateOpenSettleDistribute, submitSettle, - type ChannelRpc, type UptoSvmSigner, } from "./channel"; import { @@ -41,6 +53,7 @@ import { type UptoChannelRecord, type UptoChannelStorage, } from "./channelStorage"; +import { assertUptoFacilitatorSigner, type UptoFacilitatorSigner } from "./signer"; import { UptoSvmRentCleanupManager } from "./rentCleanupManager"; /** Scheme-specific error returned when the settlement amount exceeds the ceiling. */ @@ -65,15 +78,6 @@ export const ERR_CHANNEL_LIFETIME_EXCEEDED = "invalid_upto_svm_payload_channel_l /** Payload `expiresAt` later than `now + maxTimeoutSeconds` (+ skew). */ export const ERR_EXPIRES_AT_MISMATCH = "invalid_upto_svm_payload_expires_at_mismatch"; -/** - * A claim's settle_and_seal + distribute transaction broadcast but did not - * reach `confirmed` within the polling budget. Unlike `transaction_failed`, - * the outcome is unknown, not a rejection — the transaction may still land. - * The settlement dedup entry is kept (not deleted) so a caller retrying the - * same claim cannot race a second settle_and_seal against the first. - */ -export const ERR_SETTLEMENT_CONFIRMATION_TIMEOUT = "settlement_confirmation_timeout"; - /** Default facilitator `maxChannelLifetimeSecs` (1 hour). */ export const DEFAULT_MAX_CHANNEL_LIFETIME_SECS = 3_600; @@ -102,13 +106,6 @@ function assertLimit(name: string, value: number | undefined, min: number): void /** Optional configuration for the upto SVM facilitator. */ export interface UptoSvmFacilitatorConfig { - /** Custom RPC URL (per-network defaults are used when omitted). */ - rpcUrl?: string; - /** - * Injected RPC client used instead of building one from `rpcUrl`. Lets the - * host route channel sends through its own paced/instrumented transport. - */ - rpc?: ChannelRpc; /** * Channel storage for rent cleanup. Defaults to in-memory storage. * Inject a durable implementation for multi-process facilitators. @@ -171,6 +168,16 @@ export interface UptoSvmFacilitatorConfig { * Default: `DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT` (100,000) */ settleComputeUnitLimit?: number; + /** + * Lets a retried deposit (open) or claim (settle_and_seal + distribute) + * settle for the same channel reconcile against an already-broadcast + * signature instead of re-broadcasting (see {@link PendingSettlementStore}). + * Defaults to a fresh in-memory store shared across all settle calls on + * this scheme instance. Inject a shared, network-backed implementation + * (e.g. Redis) for a multi-instance facilitator so a settle retry landing + * on a different replica still reconciles correctly. + */ + pendingSettlementStore?: PendingSettlementStore; } type OpenAuthFailure = { @@ -212,6 +219,8 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { private readonly config: UptoSvmFacilitatorConfig; private readonly channelStorage: UptoChannelStorage; private readonly settlementCache = new SettlementCache(); + private readonly pendingStore: PendingSettlementStore; + private readonly signer: UptoFacilitatorSigner; private readonly getKitSigner: (feePayer: Address) => FacilitatorSigningCapabilities; @@ -220,31 +229,27 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { * * @param signer - Facilitator signer (fee payers / channel rent payers / * zero-share channel payees). `getExtra` randomly selects among - * `signer.getAddresses()`. Must provide {@link FacilitatorSvmSigner.getSigner}. - * @param config - Optional RPC / channel-storage configuration + * `signer.getAddresses()`. Must expose the optional read RPC caps + * ({@link FacilitatorSvmSigner.getAccountInfo}, etc.) — typically via + * {@link toFacilitatorSvmSigner}. + * @param config - Optional channel-storage configuration */ - constructor( - private readonly signer: FacilitatorSvmSigner, - config: UptoSvmFacilitatorConfig = {}, - ) { - if (typeof signer.getSigner !== "function") { - throw new Error( - "UptoSvmScheme requires getSigner on the signer. " + - "Use toFacilitatorSvmSigner() which provides all required methods.", - ); - } - this.getKitSigner = signer.getSigner.bind(signer); - if (this.signer.getAddresses().length === 0) { - throw new Error("UptoSvmScheme requires at least one fee payer signer"); - } + constructor(signer: FacilitatorSvmSigner, config: UptoSvmFacilitatorConfig = {}) { assertLimit("maxChannelLifetimeSecs", config.maxChannelLifetimeSecs, 1); assertLimit("maxPriorityFeeMicroLamports", config.maxPriorityFeeMicroLamports, 0); assertLimit("maxComputeUnits", config.maxComputeUnits, 1); assertLimit("maxRequiredSignatures", config.maxRequiredSignatures, 1); assertLimit("computeUnitPriceMicroLamports", config.computeUnitPriceMicroLamports, 0); assertLimit("settleComputeUnitLimit", config.settleComputeUnitLimit, 1); + assertUptoFacilitatorSigner(signer); + this.signer = signer; + this.getKitSigner = signer.getSigner.bind(signer); + if (this.signer.getAddresses().length === 0) { + throw new Error("UptoSvmScheme requires at least one fee payer signer"); + } this.config = config; this.channelStorage = config.channelStorage ?? new InMemoryUptoChannelStorage(); + this.pendingStore = config.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); } /** @@ -258,20 +263,28 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { /** * Create a {@link UptoSvmRentCleanupManager} for the given network, wired to - * this scheme's signer pool and channel storage. Does not auto-start; call + * this scheme's channel storage. Does not auto-start; call * `manager.start(...)` or schedule `manager.cleanup()`. * * @param network - CAIP-2 network the manager should clean up + * @param options - Optional overrides (e.g. a cleanup-only signer on a slower RPC) + * @param options.signer - Facilitator signer for cleanup RPC (defaults to this scheme's signer) * @returns A rent cleanup manager for that network */ - createRentCleanupManager(network: Network): UptoSvmRentCleanupManager { + createRentCleanupManager( + network: Network, + options?: { signer?: FacilitatorSvmSigner }, + ): UptoSvmRentCleanupManager { + let cleanupSigner: UptoFacilitatorSigner = this.signer; + if (options?.signer) { + assertUptoFacilitatorSigner(options.signer, "UptoSvmRentCleanupManager"); + cleanupSigner = options.signer; + } return new UptoSvmRentCleanupManager({ computeUnitPriceMicroLamports: this.config.computeUnitPriceMicroLamports, network, - rpcUrl: this.config.rpcUrl, settleComputeUnitLimit: this.config.settleComputeUnitLimit, - rpc: this.config.rpc, - signer: this.signer, + signer: cleanupSigner, storage: this.channelStorage, }); } @@ -372,23 +385,144 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { return this.settleClaim(payload, requirements, p, actual, payloadMaxAmount); } if (actual === payloadMaxAmount) { - return this.settleDeposit(payload, requirements); + return this.settleDeposit(payload, requirements, p); } return this.settleFailure(payload, "invalid_upto_svm_payload_missing_voucher", p.from); } + /** + * Re-awaits confirmation of a signature previously recorded in the + * `PendingSettlementStore` under `pendingKey`, without re-verifying, + * re-signing, or re-broadcasting. Re-broadcasting is not a safe fallback + * here: the deposit's channel PDA is one-shot (a second open would hit + * `ERR_CHANNEL_ALREADY_OPEN`) and a claim seals the channel (a second + * claim attempt would fail the "channel is not open" check) — either of + * which would misreport an already-successful payment as failed. Mirrors + * Go's `awaitPendingUptoSignature`. + * + * @param pendingKey - The pending-settlement store key for this deposit/claim + * @param dedupKey - The settlementCache dedup key to release on terminal failure; + * for claim this equals pendingKey, but for deposit it's channel-scoped while + * pendingKey is bound to the exact open transaction bytes + * @param signature - The previously broadcast signature to re-await + * @param payer - Payer address for the response + * @param network - The network the transaction was broadcast to + * @returns `{ ok: true }` on confirmation (store entry cleared), or + * `{ ok: false, response }` with a `settlement_pending` (non-terminal) or + * `transaction_failed` (terminal) response to surface + */ + private async awaitPendingUptoSignature( + pendingKey: string, + dedupKey: string, + signature: string, + payer: string, + network: PaymentRequirements["network"], + ): Promise<{ ok: true } | { ok: false; response: SettleResponse }> { + try { + await this.signer.confirmTransaction(signature, network); + } catch (error) { + if (error instanceof TransactionOnchainFailureError) { + // Definite onchain rejection: release the dedup lock set by the + // original call so a fresh attempt for this channel isn't blocked. + this.settlementCache.delete(dedupKey); + return { + ok: false, + response: { + success: false, + errorReason: "transaction_failed", + errorMessage: error.message, + transaction: signature, + network, + payer, + }, + }; + } + return { + ok: false, + response: await recordPendingOrTerminal( + this.pendingStore, + pendingKey, + signature, + payer, + network, + ErrSettlementPending, + "transaction_failed", + error, + ), + }; + } + try { + await this.pendingStore.delete(pendingKey); + } catch { + // Best-effort cleanup; the confirmed settlement is correct regardless + // and must not be masked by a storage hiccup. + } + return { ok: true }; + } + /** * Deposit path: validate open authorization, then sim → broadcast → bind. * Rejects when the channel already exists (one request, one open). * * @param payload - The payment payload * @param requirements - Requirements with amount = authorized ceiling + * @param p - Typed upto payload (channelId is needed before open validation) * @returns Deposit settlement response */ private async settleDeposit( payload: PaymentPayload, requirements: PaymentRequirements, + p: UptoSvmPayloadV2, ): Promise { + // settlementCache dedup key: channel-scoped (not tied to exact transaction + // bytes) so concurrent settles for the same channel with differently-signed + // opens are still caught (see the race comment below). + const depositChannelKey = `upto:deposit:${requirements.network}:${p.channelId}`; + + // Pending-settlement fast path: a prior deposit settle for this exact open + // transaction broadcast successfully but couldn't confirm in time. Reconcile + // against that signature instead of re-broadcasting (a second open would hit + // ERR_CHANNEL_ALREADY_OPEN). Keyed on the message hash, not just channelId, + // so a differently-shaped retry (e.g. mismatched deposit amount) falls + // through to full validation instead of trusting a stale signature. Mirrors + // `exact`'s txKey. + let depositKey: string | undefined; + try { + depositKey = `upto:deposit:${requirements.network}:${transactionMessageHash( + decodeTransactionFromPayload({ transaction: p.openTransaction }), + )}`; + } catch { + depositKey = undefined; + } + + if (depositKey) { + const cachedDepositSignature = await this.pendingStore.get(depositKey); + if (cachedDepositSignature) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the settlementCache dedup check, which + // independently rejects it as a duplicate. + await this.pendingStore.delete(depositKey); + const pending = await this.awaitPendingUptoSignature( + depositKey, + depositChannelKey, + cachedDepositSignature, + p.from, + payload.accepted.network, + ); + if (!pending.ok) { + return pending.response; + } + return { + success: true, + transaction: cachedDepositSignature, + network: requirements.network, + amount: p.maxAmount, + payer: p.from, + }; + } + } + const auth = await this.validateOpenAuthorization(payload, requirements, { rejectVoucher: true, }); @@ -403,34 +537,39 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }; } - const { p, channelConfig, feePayerSigner, maxAmount, tokenProgram } = auth.ctx; + const { channelConfig, feePayerSigner, maxAmount, tokenProgram } = auth.ctx; const feePayer = channelConfig.feePayer; - const rpc = this.config.rpc ?? createRpcClient(requirements.network, this.config.rpcUrl); + const network = requirements.network; // One authorization → one deposit open. A confirmed channel is replay or a // stranded prior open, not a supported re-bind path; handler failure after // a successful deposit uses the zero-amount cancel/refund settle instead. - if (await channelExists(rpc, p.channelId)) { + if (await channelExists(this.signer, network, p.channelId)) { return this.settleFailure(payload, ERR_CHANNEL_ALREADY_OPEN, p.from); } // Race: two deposit settles can both see "channel missing", broadcast the // same open, and both get success back from RPC. Dedup here so only one // proceeds. Key is deposit-scoped so this does not block the later claim. - const depositKey = `upto:deposit:${requirements.network}:${p.channelId}`; - if (this.settlementCache.isDuplicate(depositKey)) { + if (this.settlementCache.isDuplicate(depositChannelKey)) { return this.settleFailure(payload, "duplicate_settlement", p.from); } + // Decoding succeeded above (validateOpenAuthorization), so this recompute + // (only needed if the earlier attempt above failed) can't throw. + depositKey ??= `upto:deposit:${requirements.network}:${transactionMessageHash( + decodeTransactionFromPayload({ transaction: p.openTransaction }), + )}`; + // Simulate open + settle + distribute before broadcast so settlement-account // failures reject without locking the deposit. try { - await simulateOpenSettleDistribute(feePayerSigner, rpc, { + await simulateOpenSettleDistribute(feePayerSigner, this.signer, network, { openTransactionBase64: p.openTransaction, channel: { channelId: p.channelId, mint: requirements.asset, - network: requirements.network, + network, payee: feePayer, payer: p.from, rentPayer: feePayer, @@ -439,7 +578,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }, }); } catch (error) { - this.settlementCache.delete(depositKey); + this.settlementCache.delete(depositChannelKey); return { success: false, network: payload.accepted.network, @@ -463,7 +602,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { expiresAt: p.expiresAt, }); } catch (error) { - this.settlementCache.delete(depositKey); + this.settlementCache.delete(depositChannelKey); return { success: false, network: payload.accepted.network, @@ -485,7 +624,24 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { p.openTransaction, ); } catch (error) { - this.settlementCache.delete(depositKey); + // A ChannelOpenConfirmationError means the open broadcast successfully + // but confirmation couldn't be observed in time: leave the deposit dedup + // lock in place (a fresh broadcast would double-open) and record the + // signature so a retry reconciles via the fast path above instead of + // re-validating. + if (error instanceof ChannelOpenConfirmationError) { + return recordPendingOrTerminal( + this.pendingStore, + depositKey, + error.signature, + p.from, + payload.accepted.network, + ErrSettlementPending, + ERR_CHANNEL_BROADCAST, + error, + ); + } + this.settlementCache.delete(depositChannelKey); return { success: false, network: payload.accepted.network, @@ -497,7 +653,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { } try { - await fetchAndVerifyOpenChannel(rpc, p.channelId, { + await fetchAndVerifyOpenChannel(this.signer, network, p.channelId, { authorizedSigner: channelConfig.receiverAuthorizer, deposit: maxAmount, gracePeriod: channelConfig.withdrawDelay, @@ -508,7 +664,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { splits: channelConfig.splits, }); } catch (error) { - this.settlementCache.delete(depositKey); + this.settlementCache.delete(depositChannelKey); return { success: false, network: payload.accepted.network, @@ -519,6 +675,12 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }; } + try { + await this.pendingStore.delete(depositKey); + } catch { + // Best-effort cleanup; the confirmed deposit is correct regardless and + // must not be masked by a storage hiccup. + } return { success: true, transaction: openSignature, @@ -546,6 +708,40 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { actual: bigint, payloadMaxAmount: bigint, ): Promise { + // Pending-settlement fast path: a prior claim settle for this exact + // channel broadcast settle_and_seal + distribute successfully but + // couldn't confirm it in time. Reconcile against that signature instead + // of re-verifying and re-submitting — the channel is sealed by a + // successful settle_and_seal, so a second claim attempt would fail + // fetchAndVerifyOpenChannel's "channel is not open" check even though + // the original payment succeeded. + const settlementKey = `upto:${requirements.network}:${p.channelId}`; + const cachedClaimSignature = await this.pendingStore.get(settlementKey); + if (cachedClaimSignature) { + // Remove before reconciling (rather than after) so a concurrent retry + // of the same payload misses here instead of also reconciling: it + // falls through to the settlementCache dedup check, which + // independently rejects it as a duplicate. + await this.pendingStore.delete(settlementKey); + const pending = await this.awaitPendingUptoSignature( + settlementKey, + settlementKey, + cachedClaimSignature, + p.from, + payload.accepted.network, + ); + if (!pending.ok) { + return pending.response; + } + return { + success: true, + transaction: cachedClaimSignature, + network: requirements.network, + amount: actual.toString(), + payer: p.from, + }; + } + if (typeof p.voucherSignature !== "string" || p.voucherSignature.length === 0) { return this.settleFailure(payload, "invalid_upto_svm_payload_missing_voucher", p.from); } @@ -608,20 +804,23 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { return this.settleFailure(payload, "invalid_upto_svm_payment_requirements", p.from); } const network = requirements.network; - const rpc = this.config.rpc ?? createRpcClient(network, this.config.rpcUrl); + + const channelPromise = fetchAndVerifyOpenChannel(this.signer, network, p.channelId, { + authorizedSigner: channelConfig.receiverAuthorizer, + deposit: payloadMaxAmount, + gracePeriod: channelConfig.withdrawDelay, + mint: requirements.asset, + payee: channelConfig.feePayer, + payer: p.from, + rentPayer: channelConfig.feePayer, + splits: channelConfig.splits, + }); + const blockhashPromise = this.signer.getLatestBlockhash(network); let channel: Awaited>; + let prefetchedBlockhash: { blockhash: string; lastValidBlockHeight: bigint }; try { - channel = await fetchAndVerifyOpenChannel(rpc, p.channelId, { - authorizedSigner: channelConfig.receiverAuthorizer, - deposit: payloadMaxAmount, - gracePeriod: channelConfig.withdrawDelay, - mint: requirements.asset, - payee: channelConfig.feePayer, - payer: p.from, - rentPayer: channelConfig.feePayer, - splits: channelConfig.splits, - }); + [channel, prefetchedBlockhash] = await Promise.all([channelPromise, blockhashPromise]); } catch (error) { return { success: false, @@ -638,7 +837,6 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { // vouchers — must fail after the first claim so only one settle_and_seal + // distribute is submitted. Failures above (invalid voucher / not open) do // not insert into the cache. - const settlementKey = `upto:${network}:${p.channelId}`; if (this.settlementCache.isDuplicate(settlementKey)) { return this.settleFailure(payload, "duplicate_settlement", p.from); } @@ -672,9 +870,10 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }); const instructions: ServerInstruction[] = [...settle, distribute]; - const signature = await submitSettle(feePayerSigner, rpc, instructions, { + const signature = await submitSettle(feePayerSigner, this.signer, network, instructions, { computeUnitLimit: this.config.settleComputeUnitLimit, computeUnitPriceMicroLamports: this.config.computeUnitPriceMicroLamports, + latestBlockhash: prefetchedBlockhash, }); // Settlement is confirmed onchain past this point; storage is cleanup @@ -687,6 +886,12 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { expiresAt: p.expiresAt, }); + try { + await this.pendingStore.delete(settlementKey); + } catch { + // Best-effort cleanup, per the comment above: settlement is already + // confirmed onchain and must not be masked by a storage hiccup. + } return { success: true, transaction: signature, @@ -695,20 +900,35 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { payer: channel.payer, }; } catch (error) { - // A confirmation timeout leaves the transaction's fate unknown, not - // failed: it may still land. The dedup entry is kept, not deleted, so - // a caller retrying this claim cannot race a second settle_and_seal - // against the first while the outcome is still unresolved. - if (error instanceof SettlementConfirmationTimeoutError) { + if (error instanceof SettlementSimulationError) { + this.settlementCache.delete(settlementKey); return { success: false, network: payload.accepted.network, transaction: "", - errorReason: ERR_SETTLEMENT_CONFIRMATION_TIMEOUT, + errorReason: "invalid_upto_svm_settlement_simulation", errorMessage: error.message, payer: p.from, }; } + // A confirmation timeout leaves the transaction's fate unknown, not + // failed: it may still land. The dedup entry is kept, not deleted, so + // a caller retrying this claim cannot race a second settle_and_seal + // against the first while the outcome is still unresolved. The + // broadcast signature is recorded so that retry reconciles via the + // fast path above instead of re-verifying/re-submitting. + if (error instanceof SettlementConfirmationTimeoutError) { + return recordPendingOrTerminal( + this.pendingStore, + settlementKey, + error.signature, + p.from, + payload.accepted.network, + ErrSettlementPending, + "transaction_failed", + error, + ); + } this.settlementCache.delete(settlementKey); return { success: false, @@ -812,7 +1032,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }; } - const rpc = this.config.rpc ?? createRpcClient(requirements.network, this.config.rpcUrl); + const network = requirements.network; let openSlot: bigint; let recentSlot: bigint; let nonce: bigint; @@ -825,7 +1045,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { "requirements.extra.recentSlot", ); } else { - recentSlot = await rpc.getSlot({ commitment: SLOT_COMMITMENT }).send(); + recentSlot = await this.signer.getSlot(network, SLOT_COMMITMENT); } } catch { return { diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts new file mode 100644 index 0000000000..c12f26462e --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts @@ -0,0 +1,39 @@ +import type { Address } from "@solana/kit"; + +import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../../signer"; + +/** + * {@link FacilitatorSvmSigner} narrowed to the optional RPC caps the `upto` + * facilitator requires at runtime. Exact-only signers omit the read methods. + */ +export type UptoFacilitatorSigner = FacilitatorSvmSigner & { + getAccountInfo: NonNullable; + getLatestBlockhash: NonNullable; + getSlot: NonNullable; + getSigner(feePayer: Address): FacilitatorSigningCapabilities; +}; + +/** + * Assert a facilitator signer exposes every optional cap `upto` needs. + * + * @param signer - Facilitator signer to validate + * @param label - Scheme or component name for error messages + * @throws Error when a required capability is missing + */ +export function assertUptoFacilitatorSigner( + signer: FacilitatorSvmSigner, + label = "UptoSvmScheme", +): asserts signer is UptoFacilitatorSigner { + if (typeof signer.getSigner !== "function") { + throw new Error(`${label} requires getSigner on the signer.`); + } + if (typeof signer.getAccountInfo !== "function") { + throw new Error(`${label} requires getAccountInfo on the signer.`); + } + if (typeof signer.getLatestBlockhash !== "function") { + throw new Error(`${label} requires getLatestBlockhash on the signer.`); + } + if (typeof signer.getSlot !== "function") { + throw new Error(`${label} requires getSlot on the signer.`); + } +} diff --git a/typescript/packages/mechanisms/svm/src/utils.ts b/typescript/packages/mechanisms/svm/src/utils.ts index c2ed9fbd0f..4e3268a68f 100644 --- a/typescript/packages/mechanisms/svm/src/utils.ts +++ b/typescript/packages/mechanisms/svm/src/utils.ts @@ -1,7 +1,9 @@ -import { createHash } from "node:crypto"; +import { sha256 } from "@noble/hashes/sha256"; +import { ErrInvalidPayloadTransaction } from "./exact/facilitator/errors"; import { isAddress, getBase58Encoder, + getBase64Decoder, getBase64Encoder, getTransactionDecoder, getCompiledTransactionMessageDecoder, @@ -20,7 +22,8 @@ import { } from "@solana/kit"; import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; import { TOKEN_2022_PROGRAM_ADDRESS } from "@solana-program/token-2022"; -import type { Network, PaymentRequirements } from "@x402/core/types"; +import type { PendingSettlementStore } from "@x402/core/facilitator"; +import type { Network, PaymentRequirements, SettleResponse } from "@x402/core/types"; import { SVM_ADDRESS_REGEX, DEVNET_RPC_URL, @@ -33,6 +36,7 @@ import { } from "./constants"; import { DEFAULT_ASSETS, findDefaultAsset, getDefaultAsset } from "./defaultAssets"; import type { ExactSvmPayloadV1 } from "./types"; +import { SLOT_COMMITMENT } from "./upto/shared"; export { normalizeNetwork } from "./constants"; @@ -61,7 +65,7 @@ export function validateSvmAddress(address: string): boolean { * @returns Base64-encoded SHA-256 hash of the transaction message bytes */ export function transactionMessageHash(transaction: Transaction): string { - return createHash("sha256").update(Buffer.from(transaction.messageBytes)).digest("base64"); + return getBase64Decoder().decode(sha256(Uint8Array.from(transaction.messageBytes))); } /** @@ -78,7 +82,7 @@ export function decodeTransactionFromPayload(svmPayload: ExactSvmPayloadV1): Tra return transactionDecoder.decode(transactionBytes); } catch (error) { console.error("Error decoding transaction:", error); - throw new Error("invalid_exact_svm_payload_transaction"); + throw new Error(ErrInvalidPayloadTransaction); } } @@ -197,9 +201,9 @@ export async function resolveBlockhash( * * Prefers a server-provided slot carried in the 402 challenge * (`extra.recentSlot`) so the client needn't make its own RPC round-trip. Falls - * back to `rpc.getSlot()` when the challenge omits it or contains a malformed - * value. Default RPC commitment (`finalized`) keeps `openSlot <= clock.slot` - * true when the open lands. + * back to `rpc.getSlot({ commitment: SLOT_COMMITMENT })` when the challenge + * omits it or contains a malformed value. Finalized commitment keeps + * `openSlot <= clock.slot` true when the open lands, matching the facilitator. * * @param rpc - RPC client used for the fallback fetch * @param requirements - The payment requirements (challenge) being paid @@ -234,7 +238,7 @@ export async function resolveOpenSlot( } } - return await rpc.getSlot().send(); + return await rpc.getSlot({ commitment: SLOT_COMMITMENT }).send(); } /** @@ -312,5 +316,83 @@ export function getStablecoinTokenProgram(currency: string, network: Network): s } } +/** + * Thrown by a {@link PendingSettlementStore}-aware confirmation wait (e.g. + * `FacilitatorSvmSigner.confirmTransaction`) when the transaction reached the + * chain and failed there — a definite onchain rejection, not a + * confirmation-wait timeout whose outcome is still unknown. Callers must + * treat this as terminal: release any dedup/pending-settlement lock and + * report a failure instead of `settlement_pending`. Any other confirmation + * error (timeout or otherwise) is treated conservatively as non-terminal, + * since a fresh broadcast while the original might still land risks a + * double-spend. + */ +export class TransactionOnchainFailureError extends Error { + /** + * Create the error for a transaction that reached the chain and failed there. + * + * @param message - Description of the onchain failure, e.g. the decoded transaction error + */ + constructor(message: string) { + super(message); + this.name = "TransactionOnchainFailureError"; + } +} + +/** + * Persists `signature` under `key` in `store` so a subsequent settle attempt + * for the same payload can reconcile against it instead of re-broadcasting, + * then returns the `settlement_pending` response carrying `error`'s message. + * + * If the store write itself fails, a later retry has no record to reconcile + * against — returning `settlement_pending` regardless would let it blindly + * re-verify/re-broadcast and risk a double-send. In that case this instead + * returns a terminal failure (`terminalReason`), preserving `signature` for + * manual reconciliation. + * + * @param store - The pending-settlement store to update + * @param key - Deterministic key for this payload (e.g. a signature or channel id) + * @param signature - The broadcast signature to persist and report + * @param payer - The payer address + * @param network - Network the transaction was broadcast to + * @param pendingReason - Error reason to report when the store write succeeds + * @param terminalReason - Error reason to report when the store write fails + * @param error - The confirmation-wait error that triggered this call + * @returns The settlement_pending or terminal SettleResponse + */ +export async function recordPendingOrTerminal( + store: PendingSettlementStore, + key: string, + signature: string, + payer: string, + network: Network, + pendingReason: string, + terminalReason: string, + error: unknown, +): Promise { + try { + await store.set(key, signature); + } catch (storeError) { + return { + success: false, + errorReason: terminalReason, + errorMessage: `settlement_pending, but failed to persist for retry: ${ + storeError instanceof Error ? storeError.message : String(storeError) + }`, + transaction: signature, + network, + payer, + }; + } + return { + success: false, + errorReason: pendingReason, + errorMessage: error instanceof Error ? error.message : String(error), + transaction: signature, + network, + payer, + }; +} + // Re-export from core for backward compatibility export { convertToTokenAmount, numberToDecimalString } from "@x402/core/utils"; diff --git a/typescript/packages/mechanisms/svm/test/unit/discovery.test.ts b/typescript/packages/mechanisms/svm/test/unit/discovery.test.ts index 79564ffcd3..4bab698b67 100644 --- a/typescript/packages/mechanisms/svm/test/unit/discovery.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/discovery.test.ts @@ -6,7 +6,8 @@ import { discoverChannelsByRentPayer } from "../../src/payment-channels/discover import { getChannelEncoder } from "../../src/payment-channels/generated/accounts/channel"; import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../src/payment-channels/onchain"; import { findPaymentChannelPda } from "../../src/payment-channels/open"; -import type { ChannelRpc } from "../../src/upto/facilitator/channel"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import type { FacilitatorSvmSigner } from "../../src/signer"; /** * Builds a channel account whose payer/payee/mint/authorizedSigner/salt/ @@ -57,32 +58,31 @@ async function validDiscoveryChannel(rentPayer: string): Promise<{ pda: string; } /** - * A stub RPC exposing only the `getProgramAccounts` shape discovery uses. + * A stub signer exposing only the `getProgramAccounts` shape discovery uses. * * @param rows - Canned getProgramAccounts rows to serve - * @returns A ChannelRpc-compatible stub + * @returns A signer-compatible stub */ -function stubRpc(rows: { pubkey: string; owner: string; data: string }[]): ChannelRpc { +function stubSigner( + rows: { pubkey: string; owner: string; data: string }[], +): Pick { return { - getProgramAccounts: vi.fn().mockReturnValue({ - send: () => - Promise.resolve( - rows.map(row => ({ - account: { data: [row.data, "base64"], owner: row.owner }, - pubkey: row.pubkey, - })), - ), - }), - } as unknown as ChannelRpc; + getProgramAccounts: vi.fn().mockResolvedValue( + rows.map(row => ({ + account: { data: [row.data, "base64"] as [string, string], owner: row.owner as Address }, + pubkey: row.pubkey as Address, + })), + ), + } as unknown as Pick; } describe("discoverChannelsByRentPayer", () => { it("accepts a validated account", async () => { const rentPayer = (await generateKeyPairSigner()).address; const { pda, data } = await validDiscoveryChannel(rentPayer); - const rpc = stubRpc([{ data, owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: pda }]); + const signer = stubSigner([{ data, owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: pda }]); - const discovered = await discoverChannelsByRentPayer(rpc, rentPayer); + const discovered = await discoverChannelsByRentPayer(signer, SOLANA_DEVNET_CAIP2, rentPayer); expect(discovered).toHaveLength(1); expect(discovered[0]?.channelId).toBe(pda); @@ -93,9 +93,9 @@ describe("discoverChannelsByRentPayer", () => { const rentPayer = (await generateKeyPairSigner()).address; const { pda, data } = await validDiscoveryChannel(rentPayer); const wrongOwner = (await generateKeyPairSigner()).address; - const rpc = stubRpc([{ data, owner: wrongOwner, pubkey: pda }]); + const signer = stubSigner([{ data, owner: wrongOwner, pubkey: pda }]); - const discovered = await discoverChannelsByRentPayer(rpc, rentPayer); + const discovered = await discoverChannelsByRentPayer(signer, SOLANA_DEVNET_CAIP2, rentPayer); expect(discovered).toHaveLength(0); }); @@ -106,9 +106,9 @@ describe("discoverChannelsByRentPayer", () => { // The RPC provider claims this address matched, but the account's own // fields derive to a different PDA — never trust the filter alone. const wrongPubkey = (await generateKeyPairSigner()).address; - const rpc = stubRpc([{ data, owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: wrongPubkey }]); + const signer = stubSigner([{ data, owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: wrongPubkey }]); - const discovered = await discoverChannelsByRentPayer(rpc, rentPayer); + const discovered = await discoverChannelsByRentPayer(signer, SOLANA_DEVNET_CAIP2, rentPayer); expect(discovered).toHaveLength(0); }); @@ -116,7 +116,7 @@ describe("discoverChannelsByRentPayer", () => { it("rejects a malformed account", async () => { const rentPayer = (await generateKeyPairSigner()).address; const pda = (await generateKeyPairSigner()).address; - const rpc = stubRpc([ + const signer = stubSigner([ { data: Buffer.from([0x01, 0x02]).toString("base64"), owner: PAYMENT_CHANNELS_PROGRAM_ID, @@ -124,18 +124,19 @@ describe("discoverChannelsByRentPayer", () => { }, ]); - const discovered = await discoverChannelsByRentPayer(rpc, rentPayer); + const discovered = await discoverChannelsByRentPayer(signer, SOLANA_DEVNET_CAIP2, rentPayer); expect(discovered).toHaveLength(0); }); it("filters onchain by account size and rent_payer offset", async () => { const rentPayer = (await generateKeyPairSigner()).address; - const rpc = stubRpc([]); + const signer = stubSigner([]); - await discoverChannelsByRentPayer(rpc, rentPayer); + await discoverChannelsByRentPayer(signer, SOLANA_DEVNET_CAIP2, rentPayer); - expect(rpc.getProgramAccounts).toHaveBeenCalledWith( + expect(signer.getProgramAccounts).toHaveBeenCalledWith( + SOLANA_DEVNET_CAIP2, PAYMENT_CHANNELS_PROGRAM_ID, expect.objectContaining({ filters: [ diff --git a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts index 854d3b97fd..dafcf0fb9f 100644 --- a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { COMPUTE_BUDGET_PROGRAM_ADDRESS } from "@solana-program/compute-budget"; import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; +import * as Errors from "../../src/exact/facilitator/errors"; import { ExactSvmSchemeV1 } from "../../src/exact/v1/facilitator/scheme"; import { SettlementCache } from "../../src/settlement-cache"; import type { FacilitatorSvmSigner } from "../../src/signer"; @@ -112,7 +113,7 @@ describe("ExactSvmScheme", () => { const result = await facilitator.verify(payload, requirements); expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("unsupported_scheme"); + expect(result.invalidReason).toBe(Errors.ErrUnsupportedScheme); }); it("should reject if network does not match", async () => { @@ -153,7 +154,7 @@ describe("ExactSvmScheme", () => { // Network check happens early in Step 1 (before transaction parsing) expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("network_mismatch"); + expect(result.invalidReason).toBe(Errors.ErrNetworkMismatch); }); it("should reject if feePayer is missing", async () => { @@ -416,7 +417,7 @@ describe("ExactSvmScheme", () => { const result = await facilitator.settle(payload, requirements); expect(result.success).toBe(false); - expect(result.errorReason).toBe("unsupported_scheme"); + expect(result.errorReason).toBe(Errors.ErrUnsupportedScheme); expect(result.network).toBe(SOLANA_DEVNET_CAIP2); }); }); @@ -470,15 +471,17 @@ describe("ExactSvmScheme", () => { function setupSettleMocks(facilitator: ExactSvmScheme) { // settle() calls the internal _verify (which also reports the path), so we // mock that to isolate the duplicate-cache logic from real verification. - vi.spyOn( - facilitator as unknown as { - _verify: (...args: unknown[]) => Promise; - }, - "_verify", - ).mockResolvedValue({ - response: { isValid: true, payer: "PayerAddress" }, - verificationPath: "static", - }); + const verifySpy = vi + .spyOn( + facilitator as unknown as { + _verify: (...args: unknown[]) => Promise; + }, + "_verify", + ) + .mockResolvedValue({ + response: { isValid: true, payer: "PayerAddress" }, + verificationPath: "static", + }); (mockSigner as Record).signTransaction = vi .fn() .mockResolvedValue("signedTx"); @@ -488,11 +491,13 @@ describe("ExactSvmScheme", () => { (mockSigner as Record).confirmTransaction = vi .fn() .mockResolvedValue(undefined); + return { verifySpy }; } it("should reject duplicate settlement of the same transaction", async () => { const facilitator = new ExactSvmScheme(mockSigner); - setupSettleMocks(facilitator); + const { verifySpy } = setupSettleMocks(facilitator); + vi.spyOn(svmUtils, "getTokenPayerFromTransaction").mockReturnValue("TokenPayer111"); const payload = makePayload("sameTransactionBase64=="); @@ -502,6 +507,46 @@ describe("ExactSvmScheme", () => { const result2 = await facilitator.settle(payload, requirements); expect(result2.success).toBe(false); expect(result2.errorReason).toBe("duplicate_settlement"); + expect(result2.payer).toBe("TokenPayer111"); + expect(verifySpy).toHaveBeenCalledTimes(1); + }); + + it("should release the settlement cache when verification fails so a retry can proceed", async () => { + const facilitator = new ExactSvmScheme(mockSigner); + const verify = vi.spyOn( + facilitator as unknown as { _verify: (...args: unknown[]) => Promise }, + "_verify", + ); + verify + .mockResolvedValueOnce({ + response: { + isValid: false, + invalidReason: Errors.ErrTransactionSimulationFailed, + payer: "", + }, + verificationPath: null, + }) + .mockResolvedValueOnce({ + response: { isValid: true, payer: "PayerAddress" }, + verificationPath: "static", + }); + (mockSigner as Record).signTransaction = vi + .fn() + .mockResolvedValue("signedTx"); + (mockSigner as Record).sendTransaction = vi + .fn() + .mockResolvedValue("txSignature123"); + (mockSigner as Record).confirmTransaction = vi + .fn() + .mockResolvedValue(undefined); + + const payload = makePayload("retryAfterVerifyFail=="); + const result1 = await facilitator.settle(payload, requirements); + expect(result1.success).toBe(false); + expect(result1.errorReason).toBe(Errors.ErrTransactionSimulationFailed); + + const result2 = await facilitator.settle(payload, requirements); + expect(result2.success).toBe(true); }); it("should release the settlement cache when send/confirm fails so a retry can proceed", async () => { @@ -515,7 +560,7 @@ describe("ExactSvmScheme", () => { const payload = makePayload("retryAfterTransientFailure=="); const result1 = await facilitator.settle(payload, requirements); expect(result1.success).toBe(false); - expect(result1.errorReason).toBe("transaction_failed"); + expect(result1.errorReason).toBe(Errors.ErrTransactionFailed); const result2 = await facilitator.settle(payload, requirements); expect(result2.success).toBe(true); diff --git a/typescript/packages/mechanisms/svm/test/unit/helpers/signedTransaction.ts b/typescript/packages/mechanisms/svm/test/unit/helpers/signedTransaction.ts new file mode 100644 index 0000000000..5df40725ba --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/helpers/signedTransaction.ts @@ -0,0 +1,47 @@ +/** + * Builds genuinely signed v0 transactions for facilitator unit tests. + * Local Ed25519 verification rejects the placeholder 64-byte signatures the + * older fixtures used. + */ + +import { getBase64EncodedWireTransaction, type Address, type TransactionSigner } from "@solana/kit"; + +export type MessageSigner = Pick; + +/** + * Encodes a compiled message with real Ed25519 signatures from `signers`. + * The fee-payer slot (account 0) may be left as zeros — verify skips that index. + * + * @param messageBytes - Compiled transaction message bytes + * @param signers - Keypairs that must sign (typically every required signer except the fee payer) + * @param extraSignatures - Optional extra/placeholder signatures (e.g. fee payer zeros) + * @returns Base64 wire transaction + */ +export async function encodeSignedTransaction( + messageBytes: Uint8Array, + signers: readonly MessageSigner[], + extraSignatures: Record = {}, +): Promise { + const signatures: Record = { ...extraSignatures }; + + await Promise.all( + signers.map(async signer => { + const [dict] = await signer.signMessages([ + { content: messageBytes, signatures: {} } as never, + ]); + Object.assign(signatures, dict); + }), + ); + + return getBase64EncodedWireTransaction({ messageBytes, signatures } as never); +} + +/** + * Placeholder fee-payer signature. Verify skips index 0; settle overwrites it. + * + * @param feePayer - Fee payer address + * @returns Signature map with a 64-byte zero signature + */ +export function placeholderFeePayerSignature(feePayer: Address): Record { + return { [feePayer]: new Uint8Array(64) }; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/index.test.ts b/typescript/packages/mechanisms/svm/test/unit/index.test.ts index 1eda197a30..b91333affd 100644 --- a/typescript/packages/mechanisms/svm/test/unit/index.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/index.test.ts @@ -153,6 +153,11 @@ describe("@x402/svm", () => { confirmTransaction: async () => {}, }; + const uptoIncompleteSigner: FacilitatorSvmSigner = { + ...exactOnlySigner, + getSigner: () => ({ address: "11111111111111111111111111111111" as never }) as never, + }; + it("allows exact-only signers without getSigner", () => { expect(exactOnlySigner.getAddresses()).toHaveLength(1); expect(exactOnlySigner.getSigner).toBeUndefined(); @@ -162,9 +167,9 @@ describe("@x402/svm", () => { expect(() => new ExactSvmScheme(exactOnlySigner)).not.toThrow(); }); - it("accepts exact-only signers for UptoSvmScheme at compile time but rejects missing getSigner at runtime", () => { - expect(() => new UptoSvmScheme(exactOnlySigner)).toThrow( - "UptoSvmScheme requires getSigner on the signer", + it("accepts exact-only signers for UptoSvmScheme at compile time but rejects missing upto read RPC at runtime", () => { + expect(() => new UptoSvmScheme(uptoIncompleteSigner as never)).toThrow( + "UptoSvmScheme requires getAccountInfo on the signer", ); }); @@ -172,7 +177,7 @@ describe("@x402/svm", () => { expect( () => new UptoSvmRentCleanupManager({ - signer: exactOnlySigner, + signer: uptoIncompleteSigner as never, storage: { upsert: async () => {}, get: async () => undefined, @@ -181,7 +186,7 @@ describe("@x402/svm", () => { }, network: SOLANA_DEVNET_CAIP2, }), - ).toThrow("UptoSvmRentCleanupManager requires getSigner on the signer"); + ).toThrow("UptoSvmRentCleanupManager requires getAccountInfo on the signer"); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/pendingSettlement.test.ts b/typescript/packages/mechanisms/svm/test/unit/pendingSettlement.test.ts new file mode 100644 index 0000000000..640e67cc69 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/pendingSettlement.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; +import * as Errors from "../../src/exact/facilitator/errors"; +const { ErrSettlementPending } = Errors; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import type { FacilitatorSvmSigner } from "../../src/signer"; +import type { PaymentRequirements, PaymentPayload } from "@x402/core/types"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; +import * as svmUtils from "../../src/utils"; +import { TransactionOnchainFailureError } from "../../src/utils"; + +/** + * Pins the exact SVM facilitator's non-terminal confirm-timeout reason to the + * generic, cross-SDK `settlement_pending` wire literal (mirrors the EVM + * equivalent in `evm/test/unit/settlement-pending-reason.test.ts`). This + * reason used to be unreported entirely (a confirm-timeout simply rejected + * `settle()`'s promise); it is now unified with EVM/SVM upto so the resource + * server's automatic single retry recognizes it uniformly across + * schemes/networks. + */ +describe("ErrSettlementPending wire contract (SVM exact)", () => { + it("equals the settlement_pending wire literal the transport layers mirror", () => { + expect(ErrSettlementPending).toBe("settlement_pending"); + }); +}); + +describe("ExactSvmScheme pending-settlement store integration", () => { + let mockSigner: FacilitatorSvmSigner; + let store: PendingSettlementStore; + + const requirements: PaymentRequirements = { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "100000", + payTo: "PayToAddress11111111111111111111111111", + maxTimeoutSeconds: 3600, + extra: { feePayer: "FeePayer1111111111111111111111111111" }, + }; + + function makePayload(transaction: string): PaymentPayload { + return { + x402Version: 2, + resource: { + url: "http://example.com/protected", + description: "Test resource", + mimeType: "application/json", + }, + accepted: { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "100000", + payTo: "PayToAddress11111111111111111111111111", + maxTimeoutSeconds: 3600, + extra: { feePayer: "FeePayer1111111111111111111111111111" }, + }, + payload: { transaction }, + }; + } + + /** + * Wires a facilitator instance so `settle()`'s duplicate-cache/pending-store + * plumbing can be exercised without real transaction decoding: `_verify` is + * stubbed to succeed, and `decodeTransactionFromPayload` is stubbed to + * derive deterministic (but non-real) messageBytes from the transaction + * string — mirrors the pattern in `duplicateTx.test.ts` / + * `facilitator.test.ts`'s `setupSettleMocks`. + */ + function setupFacilitator(): ExactSvmScheme { + const facilitator = new ExactSvmScheme(mockSigner, undefined, { + pendingSettlementStore: store, + }); + vi.spyOn( + facilitator as unknown as { _verify: (...args: unknown[]) => Promise }, + "_verify", + ).mockResolvedValue({ + response: { isValid: true, payer: "PayerAddress" }, + verificationPath: "static", + }); + return facilitator; + } + + beforeEach(() => { + mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + getAddresses: vi + .fn() + .mockReturnValue([ + "FeePayer1111111111111111111111111111", + "FacilitatorAddress1111111111111111111", + ]) as never, + getSigner: vi.fn() as never, + signTransaction: vi.fn().mockResolvedValue("signedTx") as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn() as never, + sendTransaction: vi.fn().mockResolvedValue("txSignature123") as never, + confirmTransaction: vi.fn().mockResolvedValue(undefined) as never, + getRpcForNetwork: vi.fn() as never, + }; + store = new InMemoryPendingSettlementStore(); + + vi.spyOn(svmUtils, "decodeTransactionFromPayload").mockImplementation( + (payload: { transaction: string }) => + ({ messageBytes: new TextEncoder().encode(payload.transaction) }) as never, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + const facilitator = setupFacilitator(); + const payload = makePayload("cacheMissSuccessTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(result.transaction).toBe("txSignature123"); + expect(await store.get(txKey)).toBeUndefined(); + }); + + it("cache-miss + confirmTransaction fails: returns settlement_pending and populates the store keyed by the message hash", async () => { + mockSigner.confirmTransaction = vi + .fn() + .mockRejectedValue(new Error("rpc: confirmation timeout")) as never; + const facilitator = setupFacilitator(); + const payload = makePayload("cacheMissPendingTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.errorReason).toBe("settlement_pending"); + expect(result.transaction).toBe("txSignature123"); + expect(await store.get(txKey)).toBe("txSignature123"); + }); + + it("cache-miss + confirmTransaction fails onchain (terminal): returns transaction_failed and releases the dedup lock", async () => { + mockSigner.confirmTransaction = vi + .fn() + .mockRejectedValue( + new TransactionOnchainFailureError("Transaction failed onchain: {}"), + ) as never; + const facilitator = setupFacilitator(); + const payload = makePayload("terminalOnchainTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionFailed); + expect(result.transaction).toBe("txSignature123"); + // Terminal: no pending entry recorded, and the settlementCache dedup lock is + // released so a fresh broadcast for this payload isn't blocked. + expect(await store.get(txKey)).toBeUndefined(); + const retried = await facilitator.settle(makePayload("terminalOnchainTx=="), requirements); + expect(retried.success).toBe(false); + expect(retried.errorReason).toBe(Errors.ErrTransactionFailed); + }); + + it("cache-hit + confirmTransaction fails onchain during reconciliation (terminal): returns transaction_failed", async () => { + mockSigner.confirmTransaction = vi + .fn() + .mockRejectedValue( + new TransactionOnchainFailureError("Transaction failed onchain: {}"), + ) as never; + const facilitator = setupFacilitator(); + const payload = makePayload("cacheHitTerminalTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + await store.set(txKey, "cachedSignature789"); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionFailed); + expect(result.transaction).toBe("cachedSignature789"); + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + expect(await store.get(txKey)).toBeUndefined(); + }); + + it("cache-hit: skips verify/sign/send entirely and reconciles against the cached signature", async () => { + const facilitator = setupFacilitator(); + const payload = makePayload("cacheHitSuccessTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + await store.set(txKey, "cachedSignature123"); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(result.transaction).toBe("cachedSignature123"); + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + expect(mockSigner.confirmTransaction).toHaveBeenCalledWith( + "cachedSignature123", + requirements.network, + ); + expect(await store.get(txKey)).toBeUndefined(); + }); + + it("cache-hit: still-unconfirmed reconciliation returns settlement_pending again and preserves the store entry", async () => { + mockSigner.confirmTransaction = vi + .fn() + .mockRejectedValue(new Error("still not confirmed")) as never; + const facilitator = setupFacilitator(); + const payload = makePayload("cacheHitPendingTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + await store.set(txKey, "cachedSignature456"); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe("cachedSignature456"); + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + expect(await store.get(txKey)).toBe("cachedSignature456"); + }); + + it("terminal verify failure never touches the store", async () => { + const facilitator = new ExactSvmScheme(mockSigner, undefined, { + pendingSettlementStore: store, + }); + vi.spyOn( + facilitator as unknown as { _verify: (...args: unknown[]) => Promise }, + "_verify", + ).mockResolvedValue({ + response: { + isValid: false, + invalidReason: "invalid_exact_svm_payload_amount_mismatch", + payer: "PayerAddress", + }, + verificationPath: null, + }); + const payload = makePayload("terminalFailureTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_exact_svm_payload_amount_mismatch"); + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + expect(await store.get(txKey)).toBeUndefined(); + }); + + it("send failure (never broadcast) never touches the store", async () => { + mockSigner.sendTransaction = vi.fn().mockRejectedValue(new Error("rpc send failed")) as never; + const facilitator = setupFacilitator(); + const payload = makePayload("sendFailureTx=="); + const txKey = svmUtils.transactionMessageHash( + svmUtils.decodeTransactionFromPayload(payload.payload as never), + ); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionFailed); + expect(mockSigner.confirmTransaction).not.toHaveBeenCalled(); + expect(await store.get(txKey)).toBeUndefined(); + }); + + it("uses a fresh in-memory store by default when none is provided", async () => { + const facilitator = new ExactSvmScheme(mockSigner); + vi.spyOn( + facilitator as unknown as { _verify: (...args: unknown[]) => Promise }, + "_verify", + ).mockResolvedValue({ + response: { isValid: true, payer: "PayerAddress" }, + verificationPath: "static", + }); + const payload = makePayload("defaultStoreTx=="); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/requiredSignatures.test.ts b/typescript/packages/mechanisms/svm/test/unit/requiredSignatures.test.ts index 5f658bfeb4..cdbc04ff85 100644 --- a/typescript/packages/mechanisms/svm/test/unit/requiredSignatures.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/requiredSignatures.test.ts @@ -17,6 +17,8 @@ import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { encodeSignedTransaction, placeholderFeePayerSignature } from "./helpers/signedTransaction"; +import * as Errors from "../../src/exact/facilitator/errors"; const COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" as Address; @@ -60,6 +62,45 @@ async function buildTransactionWithSigners( return getBase64EncodedWireTransaction({ messageBytes, signatures } as never); } +/** + * Same as {@link buildTransactionWithSigners} but with genuine signatures on + * every extra signer so local verification can proceed past the fee-payer slot. + * + * @param feePayer - Fee payer address + * @param extraSigners - Additional writable-signer keypairs + * @returns Base64 wire transaction + */ +async function buildSignedTransactionWithSigners( + feePayer: Address, + extraSigners: Array<{ + address: Address; + signMessages: (messages: never[]) => Promise; + }>, +): Promise { + const instruction: IInstruction = { + programAddress: COMPUTE_BUDGET_PROGRAM, + accounts: extraSigners.map(s => ({ address: s.address, role: 3 as never })), + data: new Uint8Array([2, 160, 134, 1, 0]), + }; + + const msg = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayer(feePayer, m), + m => setTransactionMessageLifetimeUsingBlockhash(FAKE_BLOCKHASH, m), + m => appendTransactionMessageInstruction(instruction, m), + ); + + const messageBytes = getCompiledTransactionMessageEncoder().encode( + compileTransactionMessage(msg), + ); + + return encodeSignedTransaction( + messageBytes, + extraSigners, + placeholderFeePayerSignature(feePayer), + ); +} + describe("ExactSvmScheme maxRequiredSignatures", () => { beforeEach(() => vi.clearAllMocks()); @@ -119,7 +160,7 @@ describe("ExactSvmScheme maxRequiredSignatures", () => { it("does not apply a signature limit when the option is unset", async () => { const feePayer = await generateKeyPairSigner(); const [a, b] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); - const transaction = await buildTransactionWithSigners(feePayer.address, [a.address, b.address]); + const transaction = await buildSignedTransactionWithSigners(feePayer.address, [a, b]); const scheme = new ExactSvmScheme(mockSigner(feePayer.address) as never); @@ -138,4 +179,84 @@ describe("ExactSvmScheme maxRequiredSignatures", () => { expect(result.isValid).toBe(false); expect(result.invalidReason).not.toBe("invalid_exact_svm_payload_excessive_signers"); }); + + it("rejects a forged client signature", async () => { + const feePayer = await generateKeyPairSigner(); + const extra = await generateKeyPairSigner(); + const transaction = await buildTransactionWithSigners(feePayer.address, [extra.address]); + + const scheme = new ExactSvmScheme(mockSigner(feePayer.address) as never); + const requirements = requirementsFor(feePayer.address); + const result = await scheme.verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction }, + } as unknown as PaymentPayload, + requirements, + ); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_svm_payload_signature_invalid"); + }); + + it("rejects a missing client signature", async () => { + const feePayer = await generateKeyPairSigner(); + const extra = await generateKeyPairSigner(); + const instruction: IInstruction = { + programAddress: COMPUTE_BUDGET_PROGRAM, + accounts: [{ address: extra.address, role: 3 as never }], + data: new Uint8Array([2, 160, 134, 1, 0]), + }; + const msg = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayer(feePayer.address, m), + m => setTransactionMessageLifetimeUsingBlockhash(FAKE_BLOCKHASH, m), + m => appendTransactionMessageInstruction(instruction, m), + ); + const messageBytes = getCompiledTransactionMessageEncoder().encode( + compileTransactionMessage(msg), + ); + const transaction = await encodeSignedTransaction(messageBytes, [], { + ...placeholderFeePayerSignature(feePayer.address), + [extra.address]: new Uint8Array(0), + }); + + const scheme = new ExactSvmScheme(mockSigner(feePayer.address) as never); + const requirements = requirementsFor(feePayer.address); + const result = await scheme.verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction }, + } as unknown as PaymentPayload, + requirements, + ); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_svm_payload_signature_invalid"); + }); + + it("rejects a transaction whose account 0 is not extra.feePayer", async () => { + const feePayer = await generateKeyPairSigner(); + const otherFeePayer = await generateKeyPairSigner(); + const extra = await generateKeyPairSigner(); + const transaction = await buildSignedTransactionWithSigners(otherFeePayer.address, [extra]); + + const signer = mockSigner(feePayer.address); + signer.getAddresses = vi.fn().mockReturnValue([feePayer.address, otherFeePayer.address]); + const scheme = new ExactSvmScheme(signer as never); + const requirements = requirementsFor(feePayer.address); + const result = await scheme.verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction }, + } as unknown as PaymentPayload, + requirements, + ); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrFeePayerMismatch); + }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/server.test.ts b/typescript/packages/mechanisms/svm/test/unit/server.test.ts index 5319d3acbf..fa003eb5da 100644 --- a/typescript/packages/mechanisms/svm/test/unit/server.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/server.test.ts @@ -14,6 +14,15 @@ import { describe("ExactSvmScheme", () => { const server = new ExactSvmScheme(); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + describe("parsePrice", () => { describe("Solana Mainnet network", () => { const network = SOLANA_MAINNET_CAIP2; diff --git a/typescript/packages/mechanisms/svm/test/unit/signer.test.ts b/typescript/packages/mechanisms/svm/test/unit/signer.test.ts index f4736db008..452c8bf445 100644 --- a/typescript/packages/mechanisms/svm/test/unit/signer.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/signer.test.ts @@ -271,5 +271,174 @@ describe("SVM Signer Converters", () => { facilitator.confirmTransaction("okSignature", SOLANA_DEVNET_CAIP2), ).resolves.toBeUndefined(); }); + + it("should simulate with sigVerify disabled", async () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const simulateTransaction = vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue({ value: { err: null } }), + }); + const mockRpc = { + getBalance: vi.fn(), + getSlot: vi.fn(), + simulateTransaction, + } as never; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never, mockRpc); + await facilitator.simulateTransaction("tx", SOLANA_DEVNET_CAIP2); + + expect(simulateTransaction).toHaveBeenCalledWith( + "tx", + expect.objectContaining({ + sigVerify: false, + commitment: "confirmed", + encoding: "base64", + }), + ); + }); + + it("should honor simulateTransaction options", async () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const simulateTransaction = vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue({ value: { err: null } }), + }); + const mockRpc = { + getBalance: vi.fn(), + getSlot: vi.fn(), + simulateTransaction, + } as never; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never, mockRpc); + await facilitator.simulateTransaction("tx", SOLANA_DEVNET_CAIP2, { + sigVerify: true, + commitment: "finalized", + encoding: "base64", + replaceRecentBlockhash: true, + }); + + expect(simulateTransaction).toHaveBeenCalledWith("tx", { + sigVerify: true, + replaceRecentBlockhash: true, + commitment: "finalized", + encoding: "base64", + }); + }); + + it("should honor replaceRecentBlockhash on simulate", async () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const simulateTransaction = vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue({ value: { err: null } }), + }); + const mockRpc = { + getBalance: vi.fn(), + getSlot: vi.fn(), + simulateTransaction, + } as never; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never, mockRpc); + await facilitator.simulateTransaction("tx", SOLANA_DEVNET_CAIP2, { + replaceRecentBlockhash: true, + }); + + expect(simulateTransaction).toHaveBeenCalledWith( + "tx", + expect.objectContaining({ replaceRecentBlockhash: true }), + ); + }); + + it("should expose upto read RPC helpers from the factory", () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never); + expect(facilitator.getAccountInfo).toBeDefined(); + expect(facilitator.getLatestBlockhash).toBeDefined(); + expect(facilitator.getSlot).toBeDefined(); + expect(facilitator.getProgramAccounts).toBeDefined(); + }); + + it("should return the kit getProgramAccounts array without a context unwrap", async () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const rows = [ + { + pubkey: "ChannelPda11111111111111111111111111111111", + account: { + data: ["AQID", "base64"] as [string, string], + owner: "Program1111111111111111111111111111111111", + }, + }, + ]; + const getProgramAccounts = vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue(rows), + }); + const mockRpc = { + getBalance: vi.fn(), + getSlot: vi.fn(), + getProgramAccounts, + } as never; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never, mockRpc); + const result = await facilitator.getProgramAccounts!(SOLANA_DEVNET_CAIP2, "program", { + commitment: "confirmed", + encoding: "base64", + }); + + expect(result).toEqual(rows); + expect(getProgramAccounts).toHaveBeenCalledWith( + "program", + expect.objectContaining({ commitment: "confirmed", encoding: "base64" }), + ); + }); + + it("should send with skipPreflight enabled", async () => { + const mockSigner = { + address: "FacilitatorAddress1111111111111111111" as never, + signTransactions: vi.fn() as never, + signMessages: vi.fn().mockResolvedValue([{}]) as never, + }; + + const sendTransaction = vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue("sig"), + }); + const mockRpc = { + getBalance: vi.fn(), + getSlot: vi.fn(), + sendTransaction, + } as never; + + const facilitator = toFacilitatorSvmSigner(mockSigner as never, mockRpc); + await facilitator.sendTransaction("tx", SOLANA_DEVNET_CAIP2); + + expect(sendTransaction).toHaveBeenCalledWith( + "tx", + expect.objectContaining({ + skipPreflight: true, + preflightCommitment: "confirmed", + encoding: "base64", + }), + ); + }); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/smartWalletFallback.test.ts b/typescript/packages/mechanisms/svm/test/unit/smartWalletFallback.test.ts index c1dc53d78c..fde6b47002 100644 --- a/typescript/packages/mechanisms/svm/test/unit/smartWalletFallback.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/smartWalletFallback.test.ts @@ -3,7 +3,6 @@ import { appendTransactionMessageInstruction, createTransactionMessage, generateKeyPairSigner, - getBase64EncodedWireTransaction, getCompiledTransactionMessageEncoder, pipe, setTransactionMessageFeePayer, @@ -18,6 +17,8 @@ import { SOLANA_DEVNET_CAIP2, } from "../../src/constants"; import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; +import { encodeSignedTransaction, placeholderFeePayerSignature } from "./helpers/signedTransaction"; +import * as Errors from "../../src/exact/facilitator/errors"; const COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" as Address; const SWIG_PROGRAM = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB" as Address; @@ -74,12 +75,7 @@ async function buildSmartWalletPayload(feePayer: Address, unknownProgram: Addres }, ]); - const txWithSig = { - messageBytes: tx.messageBytes, - signatures: { [feePayer]: new Uint8Array(64) } as Record, - }; - - return getBase64EncodedWireTransaction(txWithSig as never); + return encodeSignedTransaction(tx.messageBytes, [], placeholderFeePayerSignature(feePayer)); } async function buildSmartWalletPayloadWithMemos( @@ -107,12 +103,7 @@ async function buildSmartWalletPayloadWithMemos( const tx = await buildTransaction(feePayer, baseInstructions); - const txWithSig = { - messageBytes: tx.messageBytes, - signatures: { [feePayer]: new Uint8Array(64) } as Record, - }; - - return getBase64EncodedWireTransaction(txWithSig as never); + return encodeSignedTransaction(tx.messageBytes, [], placeholderFeePayerSignature(feePayer)); } /** @@ -126,7 +117,7 @@ async function buildStaticTransferPayload(args: { source: Address; mint: Address; destination: Address; - authority: Address; + authority: { address: Address; signMessages: (messages: never[]) => Promise }; amount: bigint; discriminator?: number; trailing?: IInstruction[]; @@ -147,22 +138,18 @@ async function buildStaticTransferPayload(args: { { address: args.destination, role: 1 }, // Authority is a signer (role 3), so the compiled message requires its // signature slot in addition to the fee payer's. - { address: args.authority, role: 3 }, + { address: args.authority.address, role: 3 }, ], data, }, ...(args.trailing ?? []), ]); - const txWithSig = { - messageBytes: tx.messageBytes, - signatures: { - [args.feePayer]: new Uint8Array(64), - [args.authority]: new Uint8Array(64), - } as Record, - }; - - return getBase64EncodedWireTransaction(txWithSig as never); + return encodeSignedTransaction( + tx.messageBytes, + [args.authority], + placeholderFeePayerSignature(args.feePayer), + ); } function buildMockInnerTransfer( @@ -340,6 +327,8 @@ describe("ExactSvmScheme smart wallet fallback path", () => { expect(result.isValid).toBe(true); expect(result.payer).toBe(payer.address); + expect(result).not.toHaveProperty("matchedTransfer"); + expect(() => JSON.stringify(result)).not.toThrow(); expect(mockSigner.simulateTransactionWithInnerInstructions).toHaveBeenCalled(); }); @@ -427,7 +416,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => { ); expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("smart_wallet_multiple_matching_transfers"); + expect(result.invalidReason).toBe(Errors.ErrSmartWalletMultipleMatchingTransfers); }); it("verify rejects smart wallet transaction when fee payer is transfer authority", async () => { @@ -574,7 +563,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => { ); expect(result.isValid).toBe(false); - expect(result.invalidReason).toContain("smart_wallet_program_not_allowed"); + expect(result.invalidReason).toContain(Errors.ErrSmartWalletProgramNotAllowed); }); it("verify accepts smart wallet transaction when required memo is present and matches", async () => { @@ -909,7 +898,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => { source: source.address, mint: USDC_DEVNET_ADDRESS as Address, destination: expectedAta, - authority: payer.address, + authority: payer, amount: 100000n, discriminator: 13, // ApproveChecked }); @@ -972,7 +961,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => { source: source.address, mint: USDC_DEVNET_ADDRESS as Address, destination: expectedAta, - authority: payer.address, + authority: payer, amount: 1n, }); @@ -1121,7 +1110,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => { source: source.address, mint: USDC_DEVNET_ADDRESS as Address, destination: expectedAta, - authority: payer.address, + authority: payer, amount: 100000n, trailing: [lighthouse, lighthouse, lighthouse], }); @@ -1171,3 +1160,242 @@ describe("ExactSvmScheme smart wallet fallback path", () => { expect(mockSigner.simulateTransactionWithInnerInstructions).not.toHaveBeenCalled(); }); }); + +describe("ExactSvmScheme pending-settlement reconciliation for smart wallet settlements", () => { + beforeEach(() => { + mockAtaMap = {}; + vi.clearAllMocks(); + }); + + it("cache-hit reconciliation runs verifyPostSettlement and rejects an unconfirmed transfer (TOCTOU defense)", async () => { + const { ExactSvmScheme } = await import("../../src/exact/facilitator/scheme"); + const { InMemoryPendingSettlementStore } = await import("@x402/core/facilitator"); + const { decodeTransactionFromPayload, transactionMessageHash } = await import( + "../../src/utils" + ); + + const feePayer = await generateKeyPairSigner(); + const unknownProgram = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + const payer = await generateKeyPairSigner(); + + // A non-static-transfer-shaped transaction (unknown program at index 2), so + // the reconciliation path's cheap `hasStaticTransferLayout` re-derivation + // correctly infers this was originally a Path-2 (smart wallet) settlement. + const txBase64 = await buildSmartWalletPayload( + feePayer.address, + unknownProgram.address, + payer.address, + ); + + const accepted = { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "100000", + payTo: payTo.address, + maxTimeoutSeconds: 3600, + extra: { feePayer: feePayer.address }, + }; + const txKey = transactionMessageHash(decodeTransactionFromPayload({ transaction: txBase64 })); + const store = new InMemoryPendingSettlementStore(); + await store.set(txKey, "CachedSmartWalletSig1111111111111111111111"); + + const mockSigner = { + getAddresses: vi.fn().mockReturnValue([feePayer.address]), + getSigner: vi.fn().mockReturnValue(feePayer), + signTransaction: vi.fn(), + sendTransaction: vi.fn(), + simulateTransaction: vi.fn(), + confirmTransaction: vi.fn().mockResolvedValue(undefined), + // No matching TransferChecked found post-confirmation, and no balance + // snapshot is available during reconciliation: verifyPostSettlement + // must report unverified rather than the reconciliation path skipping + // the check entirely. + getConfirmedTransactionInnerInstructions: vi.fn().mockResolvedValue(null), + getTokenAccountBalance: vi.fn().mockResolvedValue(null), + fetchAddressLookupTables: vi.fn().mockResolvedValue({}), + simulateTransactionWithInnerInstructions: vi.fn(), + }; + + const scheme = new ExactSvmScheme(mockSigner as never, undefined, { + pendingSettlementStore: store, + enableSmartWalletVerification: true, + }); + + const result = await scheme.settle( + { + x402Version: 2, + accepted, + payload: { transaction: txBase64 }, + } as never, + accepted as never, + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("post_settlement_transfer_not_confirmed"); + expect(mockSigner.confirmTransaction).toHaveBeenCalledWith( + "CachedSmartWalletSig1111111111111111111111", + SOLANA_DEVNET_CAIP2, + ); + // Reconciliation must not re-verify/re-sign/re-send. + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + }); + + it("verify never calls signTransaction and simulates the unsigned payload", async () => { + const { ExactSvmScheme } = await import("../../src/exact/facilitator/scheme"); + + const feePayer = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + const source = await generateKeyPairSigner(); + const payer = await generateKeyPairSigner(); + const expectedAta = payTo.address; + mockAtaMap[payTo.address] = expectedAta; + + const txBase64 = await buildStaticTransferPayload({ + feePayer: feePayer.address, + source: source.address, + mint: USDC_DEVNET_ADDRESS as Address, + destination: expectedAta, + authority: payer, + amount: 100000n, + }); + + const mockSigner = { + getAddresses: vi.fn().mockReturnValue([feePayer.address]), + signTransaction: vi.fn().mockResolvedValue(txBase64), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + sendTransaction: vi.fn(), + confirmTransaction: vi.fn(), + getConfirmedTransactionInnerInstructions: vi.fn().mockResolvedValue(null), + getTokenAccountBalance: vi.fn().mockResolvedValue(null), + fetchAddressLookupTables: vi.fn().mockResolvedValue({}), + simulateTransactionWithInnerInstructions: vi + .fn() + .mockResolvedValue({ innerInstructions: [] }), + }; + + const scheme = new ExactSvmScheme(mockSigner as never); + const accepted = { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "100000", + payTo: payTo.address, + maxTimeoutSeconds: 3600, + extra: { feePayer: feePayer.address }, + }; + + const result = await scheme.verify( + { + x402Version: 2, + resource: { url: "http://test.com", description: "test", mimeType: "application/json" }, + accepted, + payload: { transaction: txBase64 }, + } as never, + accepted as never, + ); + + expect(result.isValid).toBe(true); + expect(mockSigner.signTransaction).not.toHaveBeenCalled(); + expect(mockSigner.simulateTransaction).toHaveBeenCalledWith(txBase64, SOLANA_DEVNET_CAIP2); + }); + + it("ALT transactions reach Path 2 instead of throwing", async () => { + const { ExactSvmScheme } = await import("../../src/exact/facilitator/scheme"); + + const feePayer = await generateKeyPairSigner(); + const altAddr = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + mockAtaMap[payTo.address] = payTo.address; + + const compiled = { + version: 0 as const, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 1, + }, + staticAccounts: [feePayer.address, SWIG_PROGRAM], + lifetimeToken: "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi", + instructions: [ + { + programAddressIndex: 1, + accountIndices: [2], + data: new Uint8Array([0]), + }, + ], + addressTableLookups: [ + { + lookupTableAddress: altAddr.address, + writableIndexes: [0], + readonlyIndexes: [], + }, + ], + }; + + const messageBytes = getCompiledTransactionMessageEncoder().encode(compiled); + const txBase64 = await encodeSignedTransaction( + messageBytes, + [], + placeholderFeePayerSignature(feePayer.address), + ); + + const mockSigner = { + getAddresses: vi.fn().mockReturnValue([feePayer.address]), + signTransaction: vi.fn(), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + sendTransaction: vi.fn(), + confirmTransaction: vi.fn(), + getConfirmedTransactionInnerInstructions: vi.fn().mockResolvedValue(null), + getTokenAccountBalance: vi.fn().mockResolvedValue(null), + fetchAddressLookupTables: vi.fn().mockResolvedValue({ + [altAddr.address]: [payTo.address], + }), + simulateTransactionWithInnerInstructions: vi.fn().mockResolvedValue({ + innerInstructions: [ + { + index: 0, + instructions: [ + buildMockInnerTransfer( + TOKEN_PROGRAM, + USDC_DEVNET_ADDRESS, + payTo.address as string, + payTo.address as string, + "100000", + ), + ], + }, + ], + }), + }; + + const scheme = new ExactSvmScheme(mockSigner as never, undefined, { + enableSmartWalletVerification: true, + }); + const accepted = { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "100000", + payTo: payTo.address, + maxTimeoutSeconds: 3600, + extra: { feePayer: feePayer.address }, + }; + + const result = await scheme.verify( + { + x402Version: 2, + resource: { url: "http://test.com", description: "test", mimeType: "application/json" }, + accepted, + payload: { transaction: txBase64 }, + } as never, + accepted as never, + ); + + expect(result.isValid).toBe(true); + expect(mockSigner.simulateTransactionWithInnerInstructions).toHaveBeenCalled(); + expect(mockSigner.fetchAddressLookupTables).toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/smartWalletVerification.test.ts b/typescript/packages/mechanisms/svm/test/unit/smartWalletVerification.test.ts index 828fa34b38..99505b2a8b 100644 --- a/typescript/packages/mechanisms/svm/test/unit/smartWalletVerification.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/smartWalletVerification.test.ts @@ -5,6 +5,7 @@ import { extractTransfersFromInnerInstructions, verifySmartWalletTransaction, } from "../../src/exact/facilitator/smartWalletVerification"; +import * as Errors from "../../src/exact/facilitator/errors"; import { appendTransactionMessageInstruction, createTransactionMessage, @@ -75,7 +76,7 @@ describe("assertFeePayerIsolated", () => { ]); await expect(assertFeePayerIsolated(tx as never, feePayer.address)).rejects.toThrow( - "smart_wallet_fee_payer_not_isolated", + Errors.ErrSmartWalletFeePayerNotIsolated, ); }); @@ -100,7 +101,7 @@ describe("assertFeePayerIsolated", () => { ]); await expect(assertFeePayerIsolated(tx as never, feePayer.address)).rejects.toThrow( - "smart_wallet_fee_payer_not_isolated", + Errors.ErrSmartWalletFeePayerNotIsolated, ); }); }); @@ -167,7 +168,7 @@ describe("validateComputeBudgetLimits", () => { ]); expect(() => validateComputeBudgetLimits(tx as never)).toThrow( - "smart_wallet_compute_units_too_high", + Errors.ErrSmartWalletComputeUnitsTooHigh, ); }); @@ -182,7 +183,7 @@ describe("validateComputeBudgetLimits", () => { ]); expect(() => validateComputeBudgetLimits(tx as never)).toThrow( - "smart_wallet_priority_fee_too_high", + Errors.ErrSmartWalletPriorityFeeTooHigh, ); }); @@ -213,7 +214,7 @@ describe("validateComputeBudgetLimits", () => { validateComputeBudgetLimits(tx as never, { maxComputeUnits: 100_000, }), - ).toThrow("smart_wallet_compute_units_too_high"); + ).toThrow(Errors.ErrSmartWalletComputeUnitsTooHigh); }); it("rejects unknown ComputeBudget instruction type", async () => { @@ -230,7 +231,7 @@ describe("validateComputeBudgetLimits", () => { ]); expect(() => validateComputeBudgetLimits(tx as never)).toThrow( - "smart_wallet_unsupported_compute_budget_instruction", + Errors.ErrSmartWalletUnsupportedComputeBudget, ); }); @@ -245,7 +246,7 @@ describe("validateComputeBudgetLimits", () => { ]); expect(() => validateComputeBudgetLimits(tx as never)).toThrow( - "smart_wallet_malformed_compute_budget", + Errors.ErrSmartWalletMalformedComputeBudget, ); }); }); @@ -884,7 +885,7 @@ describe("ExactSvmScheme constructor enforcement", () => { new ExactSvmScheme(incompleteSigner as never, undefined, { enableSmartWalletVerification: true, }), - ).toThrow("enableSmartWalletVerification requires"); + ).toThrow("ExactSvmScheme requires simulateTransactionWithInnerInstructions on the signer"); }); it("throws when signer has the other methods but lacks fetchAddressLookupTables", async () => { @@ -911,7 +912,7 @@ describe("ExactSvmScheme constructor enforcement", () => { new ExactSvmScheme(signerMissingAlt as never, undefined, { enableSmartWalletVerification: true, }), - ).toThrow("enableSmartWalletVerification requires fetchAddressLookupTables"); + ).toThrow("ExactSvmScheme requires fetchAddressLookupTables on the signer"); }); it("succeeds when signer has all required methods", async () => { @@ -1031,7 +1032,7 @@ describe("assertFeePayerIsolated ALT handling", () => { signerWithoutALT as never, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", ), - ).rejects.toThrow("smart_wallet_alt_resolution_not_available"); + ).rejects.toThrow(Errors.ErrSmartWalletAltResolutionUnavailable); }); it("catches fee payer hidden in ALT-resolved accounts", async () => { @@ -1090,7 +1091,7 @@ describe("assertFeePayerIsolated ALT handling", () => { signerWithALT as never, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", ), - ).rejects.toThrow("smart_wallet_fee_payer_not_isolated"); + ).rejects.toThrow(Errors.ErrSmartWalletFeePayerNotIsolated); }); it("propagates ALT resolution failure instead of treating it as no ALTs", async () => { @@ -1141,7 +1142,7 @@ describe("assertFeePayerIsolated ALT handling", () => { sendTransaction: async () => "", confirmTransaction: async () => {}, fetchAddressLookupTables: async () => { - throw new Error("smart_wallet_alt_resolution_failed: rpc unavailable"); + throw new Error(`${Errors.ErrSmartWalletAltResolutionFailed}: rpc unavailable`); }, }; @@ -1152,6 +1153,6 @@ describe("assertFeePayerIsolated ALT handling", () => { signerWithFailingALT as never, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", ), - ).rejects.toThrow("smart_wallet_alt_resolution_failed"); + ).rejects.toThrow(Errors.ErrSmartWalletAltResolutionFailed); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts index 909a5ee40b..b703f760ae 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts @@ -21,12 +21,14 @@ vi.mock("../../src/payment-channels/generated/accounts/channel", async importOri import { fetchAndVerifyOpenChannel, getChannelDistributionHash, - type ChannelRpc, type ExpectedOpenChannel, } from "../../src/upto/facilitator/channel"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import type { UptoFacilitatorSigner } from "../../src/upto/facilitator/signer"; const CHANNEL_ID = USDC_MAINNET_ADDRESS; -const rpc = {} as ChannelRpc; +const signer = {} as UptoFacilitatorSigner; +const NETWORK = SOLANA_DEVNET_CAIP2; const PAYEE = USDC_DEVNET_ADDRESS; const PAYER = USDC_MAINNET_ADDRESS; const AUTHORIZED_SIGNER = USDC_DEVNET_ADDRESS; @@ -87,7 +89,7 @@ describe("upto SVM channel reads", () => { .mockResolvedValueOnce(missingAccount) .mockResolvedValueOnce(existingAccount); - const result = fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected); + const result = fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected); await vi.advanceTimersByTimeAsync(200); await expect(result).resolves.toMatchObject({ channelId: CHANNEL_ID }); @@ -98,7 +100,7 @@ describe("upto SVM channel reads", () => { vi.useFakeTimers(); channelAccountMocks.fetchMaybeChannel.mockResolvedValue(missingAccount); - const result = fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected); + const result = fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected); const assertion = expect(result).rejects.toThrow(`channel ${CHANNEL_ID} does not exist`); await vi.advanceTimersByTimeAsync(3_000); @@ -112,7 +114,7 @@ describe("upto SVM channel reads", () => { data: { ...channel, status: 1 }, }); - await expect(fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected)).rejects.toThrow( + await expect(fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected)).rejects.toThrow( `channel ${CHANNEL_ID} is not open`, ); expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(1); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts index 4b9d48f889..cd8eecfbf8 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts @@ -30,6 +30,18 @@ vi.mock("../../src/utils", async () => { ...actual, createRpcClient: () => ({ getSlot: () => ({ send: async () => 123_456_789n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + }), + }), + getAccountInfo: () => ({ send: async () => ({ value: null }) }), + getProgramAccounts: () => ({ send: async () => [] }), + simulateTransaction: () => ({ send: async () => ({ value: { err: null } }) }), + sendTransaction: () => ({ send: async () => USDC_MAINNET_ADDRESS }), + getSignatureStatuses: () => ({ + send: async () => ({ value: [{ err: null, confirmationStatus: "confirmed" }] }), + }), }), }; }); @@ -45,23 +57,21 @@ import { ERR_CHANNEL_BROADCAST, ERR_EXPIRES_AT_MISMATCH, ERR_CHANNEL_LIFETIME_EXCEEDED, - ERR_SETTLEMENT_CONFIRMATION_TIMEOUT, ERR_UNEXPECTED_VOUCHER, UptoSvmScheme, } from "../../src/upto/facilitator/scheme"; -import { SettlementConfirmationTimeoutError } from "../../src/upto/facilitator/channel"; +import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; +import { + SettlementConfirmationTimeoutError, + SettlementSimulationError, +} from "../../src/upto/facilitator/channel"; import type { UptoChannelStorage } from "../../src/upto/facilitator/channelStorage"; import { UptoSvmRentCleanupManager } from "../../src/upto/facilitator/rentCleanupManager"; import type { UptoSvmPayloadV2 } from "../../src/types"; +import { challengeExpiresAt, MAX_TIMEOUT_SECONDS } from "./upto.testUtils"; const OPEN_SLOT = 123_456_789n; const WITHDRAW_DELAY = 900; -const MAX_TIMEOUT_SECONDS = 300; - -/** Challenge-bound expiry used by facilitator lifecycle tests. */ -function challengeExpiresAt(maxTimeoutSeconds = MAX_TIMEOUT_SECONDS): number { - return Math.floor(Date.now() / 1000) + maxTimeoutSeconds; -} describe("UptoSvmScheme facilitator channel lifecycle", () => { beforeEach(() => { @@ -185,8 +195,12 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(channelMocks.submitSettle).toHaveBeenCalledWith( expect.anything(), expect.anything(), + SOLANA_DEVNET_CAIP2, expect.anything(), - { computeUnitLimit: 123_456, computeUnitPriceMicroLamports: 7 }, + expect.objectContaining({ + computeUnitLimit: 123_456, + computeUnitPriceMicroLamports: 7, + }), ); }); @@ -382,6 +396,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(channelMocks.simulateOpenSettleDistribute).toHaveBeenCalledWith( expect.anything(), expect.anything(), + SOLANA_DEVNET_CAIP2, expect.objectContaining({ openTransactionBase64: uptoPayload.openTransaction, channel: expect.objectContaining({ @@ -826,10 +841,13 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { }); // A confirmation timeout means the transaction's fate is unknown, not - // failed — it must be reported distinctly from a definite broadcast - // failure so an operator does not treat a possibly-landed settlement as - // safe to blindly retry. - it("returns settlement_confirmation_timeout when confirmation times out", async () => { + // failed — it is reported as the generic, non-terminal settlement_pending + // reason (unified with EVM/the wire spec) with the broadcast signature + // preserved, instead of the previous terminal-looking distinct reason, so + // the resource server's automatic retry (and, on a subsequent settle + // call, this scheme's own pending-settlement fast path) can reconcile + // against it. + it("returns settlement_pending with the broadcast signature when confirmation times out", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); channelMocks.submitSettle.mockRejectedValue( @@ -849,15 +867,18 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { ), ).resolves.toMatchObject({ success: false, - errorReason: ERR_SETTLEMENT_CONFIRMATION_TIMEOUT, - transaction: "", + errorReason: ErrSettlementPending, + transaction: "Sig11111111111111111111111111111111111111111", }); }); // The claim cache must survive a confirmation timeout: the first // transaction may still land, so a retry must not be allowed to race a // second settle_and_seal against it while the outcome is unresolved. - it("keeps the claim cache entry after a confirmation timeout, blocking a retry", async () => { + // (Reconciliation of the cached signature on a subsequent settle call is + // covered by dedicated pending-settlement-store tests, which mock the + // signer's confirmTransaction directly rather than exercising a real RPC.) + it("keeps the claim cache entry after a confirmation timeout, blocking a fresh re-submit", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); channelMocks.submitSettle.mockRejectedValue( @@ -875,11 +896,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { await expect(facilitator.settle(claimPayload, claimRequirements)).resolves.toMatchObject({ success: false, - errorReason: ERR_SETTLEMENT_CONFIRMATION_TIMEOUT, - }); - await expect(facilitator.settle(claimPayload, claimRequirements)).resolves.toMatchObject({ - success: false, - errorReason: "duplicate_settlement", + errorReason: ErrSettlementPending, }); expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); }); @@ -1078,22 +1095,24 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { }); }); - it("throws when UptoSvmScheme is constructed with a signer lacking getSigner", () => { + it("throws when UptoSvmScheme is constructed with a signer lacking upto read RPC", () => { const exactOnlySigner: FacilitatorSvmSigner = { getAddresses: () => ["11111111111111111111111111111111" as never], + getSigner: vi.fn(), signTransaction: vi.fn(), simulateTransaction: vi.fn(), sendTransaction: vi.fn(), confirmTransaction: vi.fn(), }; - expect(() => new UptoSvmScheme(exactOnlySigner)).toThrow( - "UptoSvmScheme requires getSigner on the signer", + expect(() => new UptoSvmScheme(exactOnlySigner as never)).toThrow( + "UptoSvmScheme requires getAccountInfo on the signer", ); }); - it("throws when UptoSvmRentCleanupManager is constructed with a signer lacking getSigner", () => { + it("throws when UptoSvmRentCleanupManager is constructed with a signer lacking upto read RPC", () => { const exactOnlySigner: FacilitatorSvmSigner = { getAddresses: () => ["11111111111111111111111111111111" as never], + getSigner: vi.fn(), signTransaction: vi.fn(), simulateTransaction: vi.fn(), sendTransaction: vi.fn(), @@ -1102,11 +1121,33 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect( () => new UptoSvmRentCleanupManager({ - signer: exactOnlySigner, + signer: exactOnlySigner as never, storage: { upsert: vi.fn(), get: vi.fn(), list: vi.fn(), delete: vi.fn() }, network: SOLANA_DEVNET_CAIP2, }), - ).toThrow("UptoSvmRentCleanupManager requires getSigner on the signer"); + ).toThrow("UptoSvmRentCleanupManager requires getAccountInfo on the signer"); + }); + + it("returns invalid_upto_svm_settlement_simulation when claim submitSettle simulation fails", async () => { + const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = + await buildFixture(); + channelMocks.submitSettle.mockRejectedValue( + new SettlementSimulationError(new Error("sim failed")), + ); + const voucherSignature = await signVoucher(receiverAuthorizer, { + channelId: uptoPayload.channelId, + cumulativeAmount: 0n, + expiresAt: BigInt(uptoPayload.expiresAt), + }); + await expect( + facilitator.settle( + { ...payload, payload: { ...uptoPayload, voucherSignature } }, + { ...requirements, amount: "0" }, + ), + ).resolves.toMatchObject({ + success: false, + errorReason: "invalid_upto_svm_settlement_simulation", + }); }); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts new file mode 100644 index 0000000000..724b3f08ed --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts @@ -0,0 +1,385 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const channelMocks = vi.hoisted(() => ({ + broadcastOpen: vi.fn(), + channelExists: vi.fn(), + fetchAndVerifyOpenChannel: vi.fn(), + simulateOpenSettleDistribute: vi.fn(), + submitSettle: vi.fn(), +})); + +vi.mock("../../src/upto/facilitator/channel", async () => { + const actual = await vi.importActual( + "../../src/upto/facilitator/channel", + ); + return { + ...actual, + broadcastOpen: channelMocks.broadcastOpen, + channelExists: channelMocks.channelExists, + fetchAndVerifyOpenChannel: channelMocks.fetchAndVerifyOpenChannel, + simulateOpenSettleDistribute: channelMocks.simulateOpenSettleDistribute, + submitSettle: channelMocks.submitSettle, + }; +}); + +vi.mock("../../src/utils", async () => { + const actual = await vi.importActual("../../src/utils"); + return { + ...actual, + createRpcClient: () => ({ + getSlot: () => ({ send: async () => 123_456_789n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + }), + }), + }), + }; +}); + +import { TOKEN_PROGRAM_ADDRESS, SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { buildOpenPaymentChannelTransaction } from "../../src/payment-channels/open"; +import { signVoucher } from "../../src/payment-channels/voucher"; +import { toFacilitatorSvmSigner } from "../../src/signer"; +import { + decodeTransactionFromPayload, + transactionMessageHash, + TransactionOnchainFailureError, +} from "../../src/utils"; +import { UptoSvmScheme } from "../../src/upto/facilitator/scheme"; +import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; +import { + ChannelOpenConfirmationError, + SettlementConfirmationTimeoutError, +} from "../../src/upto/facilitator/channel"; +import type { UptoSvmPayloadV2 } from "../../src/types"; +import { challengeExpiresAt, MAX_TIMEOUT_SECONDS } from "./upto.testUtils"; + +const OPEN_SLOT = 123_456_789n; +const WITHDRAW_DELAY = 900; + +/** + * Wires a real (unmocked-at-the-signature-level) open channel + payload fixture + * matching `upto.facilitator.test.ts`'s `buildFixture`, but exposes the raw + * `FacilitatorSvmSigner` so pending-settlement-store tests can override + * `confirmTransaction` without touching real RPC. + */ +async function buildFixture(config: ConstructorParameters[1] = {}) { + const payer = await generateKeyPairSigner(); + const feePayer = await generateKeyPairSigner(); + const receiverAuthorizer = await generateKeyPairSigner(); + const open = await buildOpenPaymentChannelTransaction({ + authorizedSigner: receiverAuthorizer.address, + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 0n }, + deposit: 1_000_000n, + feePayer: feePayer.address, + gracePeriod: WITHDRAW_DELAY, + mint: USDC_DEVNET_ADDRESS, + openSlot: OPEN_SLOT, + payee: feePayer.address, + payer, + recipients: [{ bps: 10_000, recipient: receiverAuthorizer.address }], + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const requirements: PaymentRequirements = { + scheme: "upto", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "1000000", + payTo: receiverAuthorizer.address, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { + feePayer: feePayer.address, + recentSlot: OPEN_SLOT.toString(), + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }, + }; + const uptoPayload: UptoSvmPayloadV2 = { + authorizedSigner: receiverAuthorizer.address, + channelId: open.channelId, + deposit: "1000000", + expiresAt: challengeExpiresAt(), + from: payer.address, + maxAmount: "1000000", + nonce: open.salt.toString(), + openSlot: OPEN_SLOT.toString(), + openTransaction: open.transaction, + validAfter: 0, + }; + const payload: PaymentPayload = { + x402Version: 2, + accepted: requirements, + payload: uptoPayload as unknown as Record, + }; + channelMocks.fetchAndVerifyOpenChannel.mockResolvedValue({ + authorizedSigner: receiverAuthorizer.address, + channelId: open.channelId, + deposit: 1_000_000n, + mint: requirements.asset, + openSlot: OPEN_SLOT, + payee: feePayer.address, + payer: payer.address, + rentPayer: feePayer.address, + splits: [{ bps: 10_000, recipient: receiverAuthorizer.address }], + }); + const rawSigner = toFacilitatorSvmSigner(feePayer); + const facilitator = new UptoSvmScheme(rawSigner, config); + return { + facilitator, + payload, + requirements, + receiverAuthorizer, + uptoPayload, + rawSigner, + feePayer, + }; +} + +/** + * Mirrors `UptoSvmScheme.settleDeposit`'s pending-settlement key: the open + * transaction's message hash (not just channelId), so a differently-shaped + * retry can't reconcile against a stale signature. + */ +function depositKeyFor(requirements: PaymentRequirements, uptoPayload: UptoSvmPayloadV2): string { + return `upto:deposit:${requirements.network}:${transactionMessageHash( + decodeTransactionFromPayload({ transaction: uptoPayload.openTransaction }), + )}`; +} + +describe("UptoSvmScheme deposit pending-settlement store integration", () => { + let store: PendingSettlementStore; + + beforeEach(() => { + vi.clearAllMocks(); + channelMocks.channelExists.mockResolvedValue(false); + channelMocks.simulateOpenSettleDistribute.mockResolvedValue(undefined); + channelMocks.broadcastOpen.mockResolvedValue(USDC_MAINNET_ADDRESS); + channelMocks.submitSettle.mockResolvedValue(USDC_MAINNET_ADDRESS); + store = new InMemoryPendingSettlementStore(); + }); + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + const { facilitator, payload, requirements, uptoPayload } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(channelMocks.broadcastOpen).toHaveBeenCalledTimes(1); + expect(await store.get(depositKey)).toBeUndefined(); + }); + + it("cache-miss + open confirmation timeout: returns settlement_pending and populates the store keyed by the channel", async () => { + channelMocks.broadcastOpen.mockRejectedValue( + new ChannelOpenConfirmationError( + "OpenSig1111111111111111111111111111111111111", + new Error("rpc: confirmation timeout"), + ), + ); + const { facilitator, payload, requirements, uptoPayload } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.errorReason).toBe("settlement_pending"); + expect(result.transaction).toBe("OpenSig1111111111111111111111111111111111111"); + expect(await store.get(depositKey)).toBe("OpenSig1111111111111111111111111111111111111"); + }); + + it("cache-miss + open confirmation fails onchain (terminal): returns transaction_failed and releases the dedup lock", async () => { + channelMocks.broadcastOpen.mockRejectedValue( + new TransactionOnchainFailureError("Transaction failed onchain: {}"), + ); + const { facilitator, payload, requirements, uptoPayload } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_upto_svm_channel_broadcast"); + expect(await store.get(depositKey)).toBeUndefined(); + }); + + it("cache-hit: skips channelExists/broadcastOpen entirely and reconciles against the cached signature", async () => { + const { facilitator, payload, requirements, uptoPayload, rawSigner } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + await store.set(depositKey, "CachedOpenSig111111111111111111111111111111"); + const confirmTransaction = vi.fn().mockResolvedValue(undefined); + rawSigner.confirmTransaction = confirmTransaction; + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(result.transaction).toBe("CachedOpenSig111111111111111111111111111111"); + expect(channelMocks.channelExists).not.toHaveBeenCalled(); + expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); + expect(channelMocks.simulateOpenSettleDistribute).not.toHaveBeenCalled(); + expect(confirmTransaction).toHaveBeenCalledWith( + "CachedOpenSig111111111111111111111111111111", + requirements.network, + ); + expect(await store.get(depositKey)).toBeUndefined(); + }); + + it("cache-hit still pending: returns settlement_pending again and preserves the store entry", async () => { + const { facilitator, payload, requirements, uptoPayload, rawSigner } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + await store.set(depositKey, "CachedOpenSig222222222222222222222222222222"); + rawSigner.confirmTransaction = vi.fn().mockRejectedValue(new Error("still not confirmed")); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe("CachedOpenSig222222222222222222222222222222"); + expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); + expect(await store.get(depositKey)).toBe("CachedOpenSig222222222222222222222222222222"); + }); + + it("terminal failure (channel already open) never touches the store", async () => { + channelMocks.channelExists.mockResolvedValue(true); + const { facilitator, payload, requirements, uptoPayload } = await buildFixture({ + pendingSettlementStore: store, + }); + const depositKey = depositKeyFor(requirements, uptoPayload); + + const result = await facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_upto_svm_channel_already_open"); + expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); + expect(await store.get(depositKey)).toBeUndefined(); + }); +}); + +describe("UptoSvmScheme claim pending-settlement store integration", () => { + let store: PendingSettlementStore; + + beforeEach(() => { + vi.clearAllMocks(); + channelMocks.channelExists.mockResolvedValue(false); + channelMocks.simulateOpenSettleDistribute.mockResolvedValue(undefined); + channelMocks.broadcastOpen.mockResolvedValue(USDC_MAINNET_ADDRESS); + channelMocks.submitSettle.mockResolvedValue(USDC_MAINNET_ADDRESS); + store = new InMemoryPendingSettlementStore(); + }); + + async function buildClaimPayload(fixture: Awaited>) { + const voucherSignature = await signVoucher(fixture.receiverAuthorizer, { + channelId: fixture.uptoPayload.channelId, + cumulativeAmount: 0n, + expiresAt: BigInt(fixture.uptoPayload.expiresAt), + }); + return { + payload: { ...fixture.payload, payload: { ...fixture.uptoPayload, voucherSignature } }, + requirements: { ...fixture.requirements, amount: "0" }, + }; + } + + it("cache-miss + broadcast success: leaves no pending entry", async () => { + const fixture = await buildFixture({ pendingSettlementStore: store }); + const { payload, requirements } = await buildClaimPayload(fixture); + const settlementKey = `upto:${requirements.network}:${fixture.uptoPayload.channelId}`; + + const result = await fixture.facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(await store.get(settlementKey)).toBeUndefined(); + }); + + it("cache-miss + settle confirmation timeout: returns settlement_pending and populates the store keyed by the channel", async () => { + channelMocks.submitSettle.mockRejectedValue( + new SettlementConfirmationTimeoutError( + "ClaimSig1111111111111111111111111111111111111" as never, + ), + ); + const fixture = await buildFixture({ pendingSettlementStore: store }); + const { payload, requirements } = await buildClaimPayload(fixture); + const settlementKey = `upto:${requirements.network}:${fixture.uptoPayload.channelId}`; + + const result = await fixture.facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.errorReason).toBe("settlement_pending"); + expect(result.transaction).toBe("ClaimSig1111111111111111111111111111111111111"); + expect(await store.get(settlementKey)).toBe("ClaimSig1111111111111111111111111111111111111"); + }); + + it("cache-hit: skips fetchAndVerifyOpenChannel/submitSettle entirely and reconciles against the cached signature", async () => { + const fixture = await buildFixture({ pendingSettlementStore: store }); + const { payload, requirements } = await buildClaimPayload(fixture); + const settlementKey = `upto:${requirements.network}:${fixture.uptoPayload.channelId}`; + await store.set(settlementKey, "CachedClaimSig11111111111111111111111111111"); + const confirmTransaction = vi.fn().mockResolvedValue(undefined); + fixture.rawSigner.confirmTransaction = confirmTransaction; + + const result = await fixture.facilitator.settle(payload, requirements); + + expect(result.success).toBe(true); + expect(result.transaction).toBe("CachedClaimSig11111111111111111111111111111"); + expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); + expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(confirmTransaction).toHaveBeenCalledWith( + "CachedClaimSig11111111111111111111111111111", + requirements.network, + ); + expect(await store.get(settlementKey)).toBeUndefined(); + }); + + it("cache-hit still pending: returns settlement_pending again and preserves the store entry", async () => { + const fixture = await buildFixture({ pendingSettlementStore: store }); + const { payload, requirements } = await buildClaimPayload(fixture); + const settlementKey = `upto:${requirements.network}:${fixture.uptoPayload.channelId}`; + await store.set(settlementKey, "CachedClaimSig22222222222222222222222222222"); + fixture.rawSigner.confirmTransaction = vi + .fn() + .mockRejectedValue(new Error("still not confirmed")); + + const result = await fixture.facilitator.settle(payload, requirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(ErrSettlementPending); + expect(result.transaction).toBe("CachedClaimSig22222222222222222222222222222"); + expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(await store.get(settlementKey)).toBe("CachedClaimSig22222222222222222222222222222"); + }); + + it("terminal failure (settlement exceeds signed ceiling) never touches the store", async () => { + const fixture = await buildFixture({ pendingSettlementStore: store }); + const { payload } = await buildClaimPayload(fixture); + const settlementKey = `upto:${fixture.requirements.network}:${fixture.uptoPayload.channelId}`; + + const result = await fixture.facilitator.settle(payload, { + ...fixture.requirements, + amount: "1000001", + }); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_upto_svm_payload_settlement_exceeds_amount"); + expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(await store.get(settlementKey)).toBeUndefined(); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts index 948030102a..9b987aab24 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts @@ -248,7 +248,7 @@ describe("UptoSvmRentCleanupManager — cleanup", () => { expect.objectContaining({ channelId: record.channelId, action: "abandon_close" }), ); expect(submitSettleMock).toHaveBeenCalledTimes(1); - const instructions = submitSettleMock.mock.calls[0]![2] as unknown[]; + const instructions = submitSettleMock.mock.calls[0]![3] as unknown[]; expect(instructions.length).toBeGreaterThanOrEqual(2); expect(await storage.get(record.channelId)).toBeUndefined(); }); @@ -280,7 +280,7 @@ describe("UptoSvmRentCleanupManager — cleanup", () => { expect(onClose).toHaveBeenCalledWith( expect.objectContaining({ channelId: record.channelId, action: "distribute" }), ); - const instructions = submitSettleMock.mock.calls[0]![2] as unknown[]; + const instructions = submitSettleMock.mock.calls[0]![3] as unknown[]; expect(instructions).toHaveLength(1); }); @@ -319,11 +319,11 @@ describe("UptoSvmRentCleanupManager — cleanup", () => { expect(onReclaim.mock.calls[0]![0].channelIds).toEqual( expect.arrayContaining([a.channelId, b.channelId]), ); - const instructions = submitSettleMock.mock.calls[0]![2] as { data: Uint8Array }[]; + const instructions = submitSettleMock.mock.calls[0]![3] as { data: Uint8Array }[]; expect(instructions).toHaveLength(2); expect(instructions.every(ix => ix.data[0] === RECLAIM_DISCRIMINATOR)).toBe(true); // Reclaim batches carry a per-channel compute-unit limit (base + 2 × per-channel). - expect(submitSettleMock.mock.calls[0]![3]).toMatchObject({ computeUnitLimit: 35_000 }); + expect(submitSettleMock.mock.calls[0]![4]).toMatchObject({ computeUnitLimit: 35_000 }); expect(await storage.get(a.channelId)).toBeUndefined(); expect(await storage.get(b.channelId)).toBeUndefined(); }); @@ -657,6 +657,14 @@ function multiKeySigner( if (!signer) throw new Error(`no signer for feePayer ${feePayer}`); return signer as unknown as TransactionSigner & { signMessages: never }; }, + getAccountInfo: vi.fn(), + getLatestBlockhash: vi.fn(), + getSlot: vi.fn().mockImplementation(() => getSlotMock()), + getProgramAccounts: vi.fn(), + signTransaction: vi.fn(), + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + confirmTransaction: vi.fn(), } as FacilitatorSvmSigner; } @@ -706,7 +714,7 @@ describe("UptoSvmRentCleanupManager — onchain discovery", () => { const onDiscover = vi.fn(); await manager.discover({ onDiscover }); - expect(discoverChannelsMock).toHaveBeenCalledWith(expect.anything(), feePayer.address); + expect(discoverChannelsMock).toHaveBeenCalledWith(expect.anything(), NETWORK, feePayer.address); expect(onDiscover).toHaveBeenCalledWith({ channelIds: [discoveredChannelId] }); // Only what the chain proves: the Open/Sealed metadata stays empty, which // a Distributed channel never needs again. diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.test.ts index 213f2e1f64..1430d11bfb 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.test.ts @@ -36,7 +36,7 @@ import { } from "../../src/payment-channels/open"; import { encodeVoucherMessageBytes, VOUCHER_MAGIC } from "../../src/payment-channels/voucher"; import { UptoSvmScheme as UptoClientScheme } from "../../src/upto/client/scheme"; -import { resolveUptoSvmMemo } from "../../src/upto/shared"; +import { resolveUptoSvmMemo, SLOT_COMMITMENT } from "../../src/upto/shared"; import { UptoSvmScheme as UptoServerScheme } from "../../src/upto/server/scheme"; import { DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT, @@ -1209,14 +1209,21 @@ describe("upto SVM scheme", () => { ).toThrow(/computeUnitPriceMicroLamports/); expect( () => - new UptoFacilitatorScheme(mockSigner as never, { - maxPriorityFeeMicroLamports: 0, - maxComputeUnits: 1, - maxRequiredSignatures: 1, - maxChannelLifetimeSecs: 1, - computeUnitPriceMicroLamports: 0, - settleComputeUnitLimit: 1, - }), + new UptoFacilitatorScheme( + toFacilitatorSvmSigner({ + address: feePayer, + signTransactions: vi.fn(), + signMessages: vi.fn(), + } as never), + { + maxPriorityFeeMicroLamports: 0, + maxComputeUnits: 1, + maxRequiredSignatures: 1, + maxChannelLifetimeSecs: 1, + computeUnitPriceMicroLamports: 0, + settleComputeUnitLimit: 1, + }, + ), ).not.toThrow(); }); @@ -1622,7 +1629,7 @@ describe("upto SVM scheme", () => { ); }); - it("simulates open + settle + distribute with sigVerify disabled", async () => { + it("simulates open + settle + distribute with replaceRecentBlockhash via the signer", async () => { const payer = await generateKeyPairSigner(); const feePayer = await generateKeyPairSigner(); const receiverAuthorizer = await generateKeyPairSigner(); @@ -1640,19 +1647,10 @@ describe("upto SVM scheme", () => { tokenProgram: TOKEN_PROGRAM_ADDRESS, }); - const simulateTransaction = vi.fn().mockReturnValue({ - send: async () => ({ value: { err: null } }), - }); - const rpc = { - getLatestBlockhash: () => ({ - send: async () => ({ - value: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, - }), - }), - simulateTransaction, - }; + const simulateTransaction = vi.fn().mockResolvedValue(undefined); + const signer = { simulateTransaction }; - await simulateOpenSettleDistribute(feePayer, rpc as never, { + await simulateOpenSettleDistribute(feePayer, signer, SOLANA_DEVNET_CAIP2, { openTransactionBase64: open.transaction, channel: { channelId: open.channelId, @@ -1666,13 +1664,9 @@ describe("upto SVM scheme", () => { }, }); - expect(simulateTransaction).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - replaceRecentBlockhash: true, - sigVerify: false, - }), - ); + expect(simulateTransaction).toHaveBeenCalledWith(expect.any(String), SOLANA_DEVNET_CAIP2, { + replaceRecentBlockhash: true, + }); }); }); @@ -1688,36 +1682,36 @@ describe("upto SVM scheme", () => { }; /** - * RPC mock capturing sent wire transactions. + * Facilitator signer mock capturing settle simulate/send/confirm. * - * @returns Mock rpc plus the simulate/send spies + * @returns Mock signer plus spies */ - function makeSettleRpc() { - const simulateTransaction = vi.fn(); - const sendSend = vi.fn().mockResolvedValue(SIG); - const sendTransaction = vi.fn().mockReturnValue({ send: sendSend }); - const rpc = { - getLatestBlockhash: () => ({ - send: async () => ({ - value: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, - }), + function makeSettleSigner() { + const simulateTransaction = vi.fn().mockResolvedValue(undefined); + const sendTransaction = vi.fn().mockResolvedValue(SIG); + const confirmTransaction = vi.fn().mockResolvedValue(undefined); + const signer = { + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: DUMMY_BLOCKHASH, + lastValidBlockHeight: 1n, }), - getSignatureStatuses: () => ({ - send: async () => ({ value: [{ err: null, confirmationStatus: "confirmed" }] }), - }), - sendTransaction, simulateTransaction, + sendTransaction, + confirmTransaction, }; - return { rpc, sendTransaction, simulateTransaction }; + return { signer, simulateTransaction, sendTransaction }; } - it("applies the static default limit and price without simulating", async () => { + it("simulates before send and applies the static default compute budget", async () => { const feePayer = await generateKeyPairSigner(); - const { rpc, sendTransaction, simulateTransaction } = makeSettleRpc(); + const { signer, simulateTransaction, sendTransaction } = makeSettleSigner(); - const signature = await submitSettle(feePayer, rpc as never, [memoIx]); + const signature = await submitSettle(feePayer, signer as never, SOLANA_DEVNET_CAIP2, [ + memoIx, + ]); expect(signature).toBe(SIG); - expect(simulateTransaction).not.toHaveBeenCalled(); + expect(simulateTransaction).toHaveBeenCalledTimes(1); + expect(sendTransaction).toHaveBeenCalledTimes(1); // Broadcast: static limit + default price, then the payload ix. const wire = sendTransaction.mock.calls[0]![0] as string; @@ -1731,11 +1725,24 @@ describe("upto SVM scheme", () => { expect(instructions[2]!.program).toBe(MEMO_PROGRAM_ADDRESS); }); + it("does not send when simulation fails", async () => { + const feePayer = await generateKeyPairSigner(); + const { signer, sendTransaction } = makeSettleSigner(); + signer.simulateTransaction.mockRejectedValue(new Error("sim failed")); + + await expect( + submitSettle(feePayer, signer as never, SOLANA_DEVNET_CAIP2, [memoIx]), + ).rejects.toThrow("sim failed"); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + it("honors the compute-unit limit override", async () => { const feePayer = await generateKeyPairSigner(); - const { rpc, sendTransaction } = makeSettleRpc(); + const { signer, sendTransaction } = makeSettleSigner(); - await submitSettle(feePayer, rpc as never, [memoIx], { computeUnitLimit: 222_222 }); + await submitSettle(feePayer, signer as never, SOLANA_DEVNET_CAIP2, [memoIx], { + computeUnitLimit: 222_222, + }); const wire = sendTransaction.mock.calls[0]![0] as string; expect(readComputeLimitData(decodeTopLevelInstructions(wire)[0]!.data)).toBe(222_222); @@ -1743,15 +1750,15 @@ describe("upto SVM scheme", () => { it("honors the compute-unit price option, omitting the instruction at 0", async () => { const feePayer = await generateKeyPairSigner(); - const priced = makeSettleRpc(); - await submitSettle(feePayer, priced.rpc as never, [memoIx], { + const priced = makeSettleSigner(); + await submitSettle(feePayer, priced.signer as never, SOLANA_DEVNET_CAIP2, [memoIx], { computeUnitPriceMicroLamports: 250, }); const pricedIxs = decodeTopLevelInstructions(priced.sendTransaction.mock.calls[0]![0]); expect(readComputePriceData(pricedIxs[1]!.data)).toBe(250n); - const unpriced = makeSettleRpc(); - await submitSettle(feePayer, unpriced.rpc as never, [memoIx], { + const unpriced = makeSettleSigner(); + await submitSettle(feePayer, unpriced.signer as never, SOLANA_DEVNET_CAIP2, [memoIx], { computeUnitPriceMicroLamports: 0, }); const unpricedIxs = decodeTopLevelInstructions(unpriced.sendTransaction.mock.calls[0]![0]); @@ -1929,7 +1936,8 @@ describe("upto SVM scheme", () => { it("resolveOpenSlot falls back to rpc.getSlot when extra.recentSlot is omitted", async () => { const getSlotSend = vi.fn().mockResolvedValue(OPEN_SLOT); - const rpc = { getSlot: () => ({ send: getSlotSend }) } as never; + const getSlot = vi.fn().mockReturnValue({ send: getSlotSend }); + const rpc = { getSlot } as never; const slot = await resolveOpenSlot(rpc, { scheme: "upto", network: SOLANA_DEVNET_CAIP2, @@ -1940,6 +1948,7 @@ describe("upto SVM scheme", () => { extra: {}, }); expect(slot).toBe(OPEN_SLOT); + expect(getSlot).toHaveBeenCalledWith({ commitment: SLOT_COMMITMENT }); expect(getSlotSend).toHaveBeenCalled(); }); @@ -1984,6 +1993,10 @@ describe("upto SVM scheme", () => { : (() => { throw new Error(`No signer for feePayer ${feePayer}`); })(), + getAccountInfo: vi.fn(), + getLatestBlockhash: vi.fn(), + getSlot: vi.fn(), + getProgramAccounts: vi.fn(), signTransaction: async () => "", simulateTransaction: async () => {}, sendTransaction: async () => "", diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.testUtils.ts b/typescript/packages/mechanisms/svm/test/unit/upto.testUtils.ts new file mode 100644 index 0000000000..bd6bb7bce1 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/upto.testUtils.ts @@ -0,0 +1,7 @@ +/** Default `maxTimeoutSeconds` used by upto fixtures across `upto.facilitator.test.ts` and `upto.pendingSettlement.test.ts`. */ +export const MAX_TIMEOUT_SECONDS = 300; + +/** Computes a voucher's `expiresAt` challenge deadline relative to now. */ +export function challengeExpiresAt(maxTimeoutSeconds = MAX_TIMEOUT_SECONDS): number { + return Math.floor(Date.now() / 1000) + maxTimeoutSeconds; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/v1/client.test.ts b/typescript/packages/mechanisms/svm/test/unit/v1/client.test.ts index 996564c592..e24c2fc6a4 100644 --- a/typescript/packages/mechanisms/svm/test/unit/v1/client.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/v1/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ExactSvmSchemeV1 } from "../../../src/exact/v1"; +import { ExactSvmSchemeV1 } from "../../../src/exact/v1/client/scheme"; import type { ClientSvmSigner } from "../../../src/signer"; import type { PaymentRequirementsV1 } from "@x402/core/types/v1"; import { USDC_DEVNET_ADDRESS } from "../../../src/defaultAssets"; diff --git a/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts index e57c08ad01..408a663be0 100644 --- a/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { COMPUTE_BUDGET_PROGRAM_ADDRESS } from "@solana-program/compute-budget"; +import { + generateKeyPairSigner, + getCompiledTransactionMessageEncoder, + type Address, +} from "@solana/kit"; import { ExactSvmSchemeV1 } from "../../../src/exact/v1/facilitator/scheme"; import type { FacilitatorSvmSigner } from "../../../src/signer"; import type { PaymentRequirementsV1 } from "@x402/core/types/v1"; @@ -7,6 +12,10 @@ import type { PaymentPayloadV1 } from "@x402/core/types/v1"; import { MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS } from "../../../src/constants"; import { USDC_DEVNET_ADDRESS } from "../../../src/defaultAssets"; import * as svmUtils from "../../../src/utils"; +import { + encodeSignedTransaction, + placeholderFeePayerSignature, +} from "../helpers/signedTransaction"; // Encodes a SetComputeUnitPrice instruction: discriminator(3) + microLamports as u64 LE function makeComputePriceData(microLamports: bigint): Uint8Array { @@ -183,6 +192,69 @@ describe("ExactSvmSchemeV1", () => { // Transaction decoding or instruction parsing fails expect(result.invalidReason).toContain("invalid_exact_svm_payload_transaction"); }); + + it("should reject address lookup table transactions with a clean invalid reason", async () => { + const feePayer = await generateKeyPairSigner(); + const altAddr = await generateKeyPairSigner(); + const computeBudget = "ComputeBudget111111111111111111111111111111" as Address; + + const compiled = { + version: 0 as const, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 1, + }, + staticAccounts: [feePayer.address, computeBudget], + lifetimeToken: "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi", + instructions: [ + { + programAddressIndex: 1, + accountIndices: [2], + data: new Uint8Array([2, 0, 0, 0, 0]), + }, + ], + addressTableLookups: [ + { + lookupTableAddress: altAddr.address, + writableIndexes: [0], + readonlyIndexes: [], + }, + ], + }; + + const messageBytes = getCompiledTransactionMessageEncoder().encode(compiled); + const transaction = await encodeSignedTransaction( + messageBytes, + [], + placeholderFeePayerSignature(feePayer.address), + ); + + mockSigner.getAddresses = vi.fn().mockReturnValue([feePayer.address]) as never; + const facilitator = new ExactSvmSchemeV1(mockSigner); + const result = await facilitator.verify( + { + x402Version: 1, + scheme: "exact", + network: "solana-devnet", + payload: { transaction }, + } as never, + { + scheme: "exact", + network: "solana-devnet", + asset: USDC_DEVNET_ADDRESS, + maxAmountRequired: "100000", + payTo: "PayToAddress11111111111111111111111111", + maxTimeoutSeconds: 3600, + extra: { feePayer: feePayer.address }, + } as never, + ); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe( + "invalid_exact_svm_payload_transaction_could_not_be_decoded", + ); + }); }); describe("verifyComputePriceInstruction (price cap)", () => { diff --git a/typescript/packages/mechanisms/tvm/CHANGELOG.md b/typescript/packages/mechanisms/tvm/CHANGELOG.md index f7c68f3431..1005202199 100644 --- a/typescript/packages/mechanisms/tvm/CHANGELOG.md +++ b/typescript/packages/mechanisms/tvm/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/tvm +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on TVM `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/tvm/package.json b/typescript/packages/mechanisms/tvm/package.json index 3c25ad77ff..39a9a815ed 100644 --- a/typescript/packages/mechanisms/tvm/package.json +++ b/typescript/packages/mechanisms/tvm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/tvm", - "version": "2.23.0", + "version": "2.24.0", "description": "TVM (TON) mechanism for x402 payment protocol", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/typescript/packages/mechanisms/tvm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/tvm/src/exact/server/scheme.ts index 9df755fae1..81e6e02306 100644 --- a/typescript/packages/mechanisms/tvm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/tvm/src/exact/server/scheme.ts @@ -18,7 +18,7 @@ export class ExactTvmScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod = "default"; readonly paymentFlows = { - default: { supported: ["authorization"], default: "authorization" }, + default: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/tvm/test/unit/exact/server.test.ts b/typescript/packages/mechanisms/tvm/test/unit/exact/server.test.ts index 43e50ed462..fc7ff9416a 100644 --- a/typescript/packages/mechanisms/tvm/test/unit/exact/server.test.ts +++ b/typescript/packages/mechanisms/tvm/test/unit/exact/server.test.ts @@ -25,6 +25,15 @@ describe("ExactTvmScheme (Server)", () => { }); }); + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + expect(server.defaultAssetTransferMethod).toBe("default"); + expect(server.paymentFlows).toEqual({ + default: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + describe("parsePrice", () => { it("should parse USD string to USDT nano", async () => { const result = await server.parsePrice("$0.01", TVM_MAINNET); diff --git a/typescript/packages/mechanisms/xrpl/CHANGELOG.md b/typescript/packages/mechanisms/xrpl/CHANGELOG.md index ff06ddd80c..d7773fcd03 100644 --- a/typescript/packages/mechanisms/xrpl/CHANGELOG.md +++ b/typescript/packages/mechanisms/xrpl/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/xrpl +## 2.24.0 + +### Minor Changes + +- [bb46ffc](https://github.com/x402-foundation/x402/commit/bb46ffc): Declare `upfront` payment flow support on XRPL `exact` server schemes. `authorization` remains the default; servers opt in per route via `accepts.extra.paymentFlow`. ([#3240](https://github.com/x402-foundation/x402/pull/3240)) - Thanks [@phdargen](https://github.com/phdargen)! +- Updated dependencies + - @x402/core@2.24.0 + ## 2.23.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/xrpl/package.json b/typescript/packages/mechanisms/xrpl/package.json index cbe857b7b6..86d489f22c 100644 --- a/typescript/packages/mechanisms/xrpl/package.json +++ b/typescript/packages/mechanisms/xrpl/package.json @@ -1,6 +1,6 @@ { "name": "@x402/xrpl", - "version": "2.23.0", + "version": "2.24.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/xrpl/src/exact/server/scheme.ts b/typescript/packages/mechanisms/xrpl/src/exact/server/scheme.ts index 5a540fb841..97d2ddc9ca 100644 --- a/typescript/packages/mechanisms/xrpl/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/xrpl/src/exact/server/scheme.ts @@ -26,8 +26,8 @@ export class ExactXrplScheme implements SchemeNetworkServer { readonly scheme = "exact"; readonly defaultAssetTransferMethod: XrplAssetTransferMethod = "sequence"; readonly paymentFlows = { - sequence: { supported: ["authorization"], default: "authorization" }, - ticketSequence: { supported: ["authorization"], default: "authorization" }, + sequence: { supported: ["authorization", "upfront"], default: "authorization" }, + ticketSequence: { supported: ["authorization", "upfront"], default: "authorization" }, } as const satisfies Record; private moneyParsers: MoneyParser[] = []; diff --git a/typescript/packages/mechanisms/xrpl/test/unit/xrpl.test.ts b/typescript/packages/mechanisms/xrpl/test/unit/xrpl.test.ts index 0cce16fee3..a4ef939caf 100644 --- a/typescript/packages/mechanisms/xrpl/test/unit/xrpl.test.ts +++ b/typescript/packages/mechanisms/xrpl/test/unit/xrpl.test.ts @@ -357,6 +357,17 @@ describe("XRPL exact utilities", () => { }); describe("ExactXrplScheme server", () => { + describe("paymentFlows", () => { + it("declares authorization and upfront with authorization as the default", () => { + const server = new ExactXrplServerScheme(); + expect(server.defaultAssetTransferMethod).toBe("sequence"); + expect(server.paymentFlows).toEqual({ + sequence: { supported: ["authorization", "upfront"], default: "authorization" }, + ticketSequence: { supported: ["authorization", "upfront"], default: "authorization" }, + }); + }); + }); + it("passes through explicit AssetAmount pricing", async () => { const server = new ExactXrplServerScheme(); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 9f9bf59899..26a0bebc3b 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -1697,6 +1697,9 @@ importers: packages/mechanisms/svm: dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@solana-program/compute-budget': specifier: ^0.11.0 version: 0.11.0(@solana/kit@6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10))