diff --git a/.env.example b/.env.example index 5417389..3cbd6bb 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org RPC_URL_TESTNET=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS_TESTNET=CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3 +# Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. +SOROSWAP_ENABLED_TESTNET=true +# Aquarius has no public testnet deployment today — disabled by default. +AQUARIUS_ENABLED_TESTNET=false REFLECTOR_CONTRACT_ID_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. @@ -53,11 +57,20 @@ RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 # Mainnet Soroswap factory contract address — see https://github.com/soroswap/core SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 +SOROSWAP_ENABLED_MAINNET=true +AQUARIUS_ENABLED_MAINNET=true # Reflector oracle contract on mainnet — see https://reflector.network REFLECTOR_CONTRACT_ID_MAINNET=CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# --- Venue endpoint overrides (optional, shared across networks unless a +# paired _TESTNET / _MAINNET variant is set) --- +# Soroswap token-list JSON URL. +SOROSWAP_TOKEN_LIST_URL=https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json +# Aquarius AMM pools API base URL. +AQUARIUS_API_URL=https://amm.aquarius.network/api/v1/pools/ + # --- Back-compat single-network vars (testnet) --- # These are still respected when the paired _TESTNET vars above are unset. # New deployments should prefer the paired vars above. @@ -91,6 +104,9 @@ REQUIRE_API_KEY=true # --- x402 Payment Gate --- # Stellar public key where API payments should be sent. # If unset, x402 gating is disabled. +# Optionally set ORACLE_PAYMENT_ADDRESS_TESTNET / ORACLE_PAYMENT_ADDRESS_MAINNET +# to use a different payout address per network (falls back to the shared +# ORACLE_PAYMENT_ADDRESS above for whichever one is unset). ORACLE_PAYMENT_ADDRESS=GD... # URL of the x402 facilitator (default: https://facilitator.stellar.org) X402_FACILITATOR_URL=https://facilitator.stellar.org diff --git a/README.md b/README.md index 7cd291f..6a4f0cb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,19 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | GET | `/pools` | Active AMM pools being watched | | GET | `/pairs` | Watched trading pairs | | GET | `/status` | Indexer health | +| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | + +Every route accepts an optional `?network=testnet\|mainnet` query param (or +`x-network` header) to pick the Stellar network — default is `testnet`. An +unrecognised value gets `400`. The `/price/*` endpoints' live SDEX pricing and +x402 payment `network`/`payTo` are fully per-request today; DB-backed reads +(candles, history, pools, AMM pricing) are still served from whichever +network this instance is currently indexing (`STELLAR_NETWORK`) — that data +layer isn't network-partitioned yet. + +```bash +curl "https://api.example.com/price/XLM/USDC?network=mainnet" +``` ### GraphQL Available at `/graphql` with GraphiQL IDE at `/graphiql`. @@ -211,13 +224,14 @@ npm run dev | `HORIZON_URL` | Stellar Horizon server URL | - | No | | `RPC_URL` | Soroban RPC server URL | - | No | | `NETWORK_PASSPHRASE` | Stellar network passphrase | - | No | -| `STELLAR_NETWORK` | `mainnet` or `testnet` (for x402 logic) | `testnet` | No | +| `STELLAR_NETWORK` | `mainnet` or `testnet` — this instance's default/ingested network | `testnet` | No | | `POLL_INTERVAL_MS` | Indexer polling frequency (ms) | `5000` | No | | `SDEX_PAGE_SIZE` | Trades per page for SDEX ingestion | `200` | No | | `AMM_PAGE_SIZE` | Trades per page for AMM ingestion | `200` | No | | `ADMIN_API_KEY` | Key for admin route authentication | - | No | | `WATCHED_PAIRS` | Comma-separated list of asset pairs to index | - | **Yes** | | `ORACLE_PAYMENT_ADDRESS` | Stellar address for x402 API payments | - | No* | +| `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | | `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | *\*Required if enabling x402 payment gating.* diff --git a/docs/x402/scheme_upto_stellar.md b/docs/x402/scheme_upto_stellar.md new file mode 100644 index 0000000..b064158 --- /dev/null +++ b/docs/x402/scheme_upto_stellar.md @@ -0,0 +1,185 @@ +# Scheme: `upto` on `Stellar` + +> Draft specification for the x402 `upto` scheme on Stellar. Stellar currently +> has a specified `exact` scheme upstream but no `upto` scheme; this document +> proposes one, matching the format of the existing `exact` Stellar spec and +> the `upto` specs for EVM and SVM in the +> [x402 protocol repository](https://github.com/x402-foundation/x402). + +## Versions supported + +- ❌ `v1` - we don't plan to support v1 for now. +- ✅ `v2` + +## Supported Networks + +This spec uses [CAIP-2](https://namespaces.chainagnostic.org/stellar/caip2) identifiers: +- `stellar:pubnet` — Stellar mainnet +- `stellar:testnet` — Stellar testnet + +## Summary + +The x402 `upto` scheme on Stellar authorizes a transfer of up to a **maximum amount**, with the actual amount settled after resource consumption is known. As with [`exact` on Stellar][scheme-exact-stellar], the client authorizes via a signed Soroban authorization entry rather than a full transaction signature, and the facilitator sponsors transaction fees. + +> [!NOTE] +> **Scope:** This spec covers [SEP-41]-compliant Soroban tokens **only**. Classic Stellar assets are not supported, matching [`exact` on Stellar][scheme-exact-stellar]. + +## Contract-vs-No-Contract Decision + +**This spec ships a Soroban contract: `x402UptoStellar`.** + +A bare [SEP-41] `approve` / `transfer_from` allowance cannot, on its own, provide the three guarantees the [`upto` core spec][scheme-upto] requires of every network implementation: + +- **Recipient binding** — a SEP-41 allowance authorizes a `spender` to move up to `amount`, to *any* address that spender chooses. It does not bind the transfer to a specific `payTo`. +- **Single-use authorization** — a SEP-41 allowance is a standing balance the spender can draw down across many calls; it has no built-in single-settlement or replay-protection semantics. +- **Time-bound authorization with an explicit `validAfter`/deadline pair** — SEP-41's `approve` supports an expiration ledger, but not a `validAfter` start bound, and nothing stops the spender from settling more than once before expiry. + +This mirrors the precedent already set by the two implemented networks: EVM does not rely on a bare ERC-20 `approve` either — it ships the purpose-built `x402UptoPermit2Proxy` contract, which wraps Permit2's witness pattern to add recipient binding, a nonce, and settle-time cap enforcement. SVM ships the `payment-channels` program for the same reason. A Stellar design that stopped at "the client approves the facilitator" would be strictly weaker than both existing implementations, not merely different from them — so this spec does not offer a contract-free path. + +The `x402UptoStellar` contract is intentionally thin: it does not escrow the client's funds up front (unlike SVM's channel model). Instead, it composes with a standard SEP-41 `approve`, and adds the missing recipient binding, nonce, and cap enforcement as a signed-authorization wrapper around `transfer_from`, closer in spirit to the EVM Permit2 witness pattern than to SVM's escrow. This keeps capital efficient (no funds are locked before settlement) and keeps the per-request signing step off-chain and free, consistent with how `exact` on Stellar already uses auth-entry signing instead of on-chain transactions for the client's half of the flow. + +## Summary of the Flow + +1. **Client** performs a one-time (or periodic) [SEP-41] `approve(spender: , amount: , expiration_ledger)` on the token contract. This is an on-chain transaction, analogous to EVM's Phase 1 Permit2 approval — it is not part of the per-request payment flow and is skipped on subsequent requests while the allowance remains sufficient and unexpired. +2. **Resource Server** responds `402 Payment Required` with `PaymentRequirements` for `upto`, including `extra.uptoContract` (the `x402UptoStellar` contract address) and `extra.facilitatorAddress`. +3. **Client** signs a Soroban authorization entry for `x402UptoStellar.settle_upto(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`, with `max_amount` set to the authorized ceiling for this request (this is the `PaymentRequirements.amount` field at verification time), `to` set to `requirements.payTo`, and `facilitator` set to `extra.facilitatorAddress`. Expiration is derived from `maxTimeoutSeconds` exactly as in `exact` on Stellar. +4. **Client** serializes the signed authorization entry (base64 XDR) and sends it to the resource server as the `PaymentPayload`. +5. **Resource Server** forwards the payload to the **Facilitator**'s `/verify` endpoint, which checks the authorization entry's structure, expiration, and that `requirements.amount <= max_amount`, without settling. +6. **Resource Server** executes the request, determines the actual cost, and calls the facilitator's `/settle` endpoint with `requirements.amount` set to the actual amount (phase-dependent `amount`, per the [`upto` core spec][scheme-upto] §5). +7. **Facilitator** re-verifies the client's authorization entry against `max_amount` (the signed ceiling, not the settlement-time amount — see [Settle-Time Verification](#settle-time-verification)), then calls `x402UptoStellar.settle_upto(auth_entry, actual_amount)`. +8. **Contract** checks the nonce has not been used, `now <= expiration_ledger`, `facilitator.require_auth()` matches the signed `facilitator`, and `actual_amount <= max_amount`; it then calls `token.transfer_from(spender: self, from, to, actual_amount)` and marks the nonce consumed. +9. **Facilitator** submits the transaction, sponsoring fees as in `exact`, and returns a `SettlementResponse` to the **Resource Server**, which grants access to the **Client**. + +## `PaymentRequirements` for `upto` + +```json +{ + "scheme": "upto", + "network": "stellar:testnet", + "amount": "10000000", + "asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + "payTo": "GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKU4APMDLQNBW5QV3W3KO", + "maxTimeoutSeconds": 300, + "extra": { + "areFeesSponsored": true, + "uptoContract": "CA...UPTOSTELLARCONTRACTADDRESS", + "facilitatorAddress": "GBFACILITATOR..." + } +} +``` + +**Field Definitions:** + +- `amount`: Phase-dependent per the [`upto` core spec][scheme-upto] — the authorized maximum at verification time, the actual settlement amount at settlement time. +- `extra.uptoContract`: The deployed `x402UptoStellar` contract address the client must authorize against. +- `extra.facilitatorAddress`: The facilitator the client binds into the signed authorization (mirrors EVM's `witness.facilitator`); prevents settlement by any other party. +- `extra.areFeesSponsored`: As in `exact` — currently always `true`. + +## PaymentPayload `payload` Field + +```json +{ + "authEntry": "AAAAAgAAAABriIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1AAAAAAAAAAAAAAAAAAAAA...", + "nonce": "3f1a...b2", + "maxAmount": "10000000" +} +``` + +- `authEntry`: Base64-encoded XDR of the signed Soroban authorization entry for `settle_upto`. +- `nonce`: The 32-byte nonce bound into the authorization entry, surfaced separately so the facilitator can perform a fast pre-check without decoding XDR. +- `maxAmount`: The signed ceiling, echoed outside the XDR for the same reason. + +## Facilitator Verification Rules (MUST) + +### 1. Protocol Validation + +Same as `exact` on Stellar: `x402Version` MUST be `2`, `scheme` MUST be `"upto"` on both sides, `network` MUST match. + +### 2. Authorization Entry Structure + +- The authorization entry MUST target the `x402UptoStellar` contract at `extra.uptoContract` and the `settle_upto` function. +- Arguments MUST be exactly `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)`. +- `to` MUST equal `requirements.payTo` exactly. +- `asset` MUST equal `requirements.asset` exactly. +- `facilitator` MUST equal `extra.facilitatorAddress` exactly. +- Credential type MUST be `sorobanCredentialsAddress`, matching `exact`. + +### 3. Cap and Amount Rules + +- At **verify** time: `requirements.amount` (the authorized maximum) MUST equal the signed `max_amount`. +- At **settle** time: `requirements.amount` (the actual settlement amount) MUST be `<= max_amount` from the signed entry. The facilitator MUST re-verify the authorization entry's signature against `max_amount`, never against the settlement-time amount — see [Settle-Time Verification](#settle-time-verification). +- The settled amount MAY be `0`. + +### 4. Time Bounds and Replay + +- `expiration_ledger` MUST NOT exceed `currentLedger + ceil(maxTimeoutSeconds / estimatedLedgerSeconds)` (fallback `5` seconds/ledger, as in `exact`). +- The facilitator MUST query the contract's nonce state (or simulate `settle_upto`) before submitting, to short-circuit already-consumed nonces. +- The contract itself is the source of truth for replay protection: `settle_upto` MUST fail if `nonce` has already been marked consumed. + +### 5. 🚨🚨🚨 Facilitator Safety + +Same as `exact` §4: the facilitator's own address MUST NOT be `from`, MUST NOT appear as an unexpected signer, and simulation MUST show only the expected balance change (`from` decrease of `actual_amount`, `to` increase of `actual_amount`) plus no other balance changes. + +### 6. Allowance Precondition + +- Before verification can succeed, the facilitator MUST confirm `token.allowance(from, uptoContract) >= max_amount` and unexpired. If insufficient, the facilitator MUST return `412 Precondition Failed` with an error code equivalent to EVM's `PERMIT2_ALLOWANCE_REQUIRED`, signaling the client to submit the one-time `approve` first. + +## Settle-Time Verification + +Identical rationale to [EVM `upto` §Settle-Time Verification][scheme-upto-evm-settle]: because `amount` is phase-dependent, the facilitator MUST: + +1. Verify the authorization entry's signature against the signed `max_amount` (the ceiling), not `requirements.amount` (the actual settlement amount) — the client signed for the ceiling, and comparing against the metered amount would reject every partial settlement. +2. Validate `requirements.amount <= max_amount`. +3. Call `settle_upto` with `actual_amount = requirements.amount`. + +A facilitator that instead enforces `requirements.amount === max_amount` at settle time will reject all partial settlements, breaking the core `upto` value proposition. + +## `x402UptoStellar` Contract + +Reference behavior (Soroban, Rust): + +- `settle_upto(from: Address, to: Address, asset: Address, max_amount: i128, facilitator: Address, nonce: BytesN<32>, expiration_ledger: u32, actual_amount: i128)` + - Requires `from`'s authorization on this invocation with the fixed args `(from, to, asset, max_amount, facilitator, nonce, expiration_ledger)` — `actual_amount` is deliberately **not** part of the signed argument set, since it is only known at settle time; this is the Stellar analogue of Permit2's witness/permitted-amount split. + - Requires `facilitator.require_auth()`, binding settlement to the designated facilitator. + - Fails if `nonce` has already been consumed for `from`. + - Fails if `env.ledger().sequence() > expiration_ledger`. + - Fails if `actual_amount > max_amount` or `actual_amount < 0`. + - On success: calls `token_client.transfer_from(&env.current_contract_address(), &from, &to, &actual_amount)`, marks `nonce` consumed, and emits a `settle` event with `(from, to, asset, actual_amount, nonce)`. +- Zero settlement (`actual_amount == 0`): the facilitator MAY skip calling `settle_upto` entirely and let the authorization entry expire unused, exactly as EVM's zero-settlement case — no on-chain transaction, no gas cost, the nonce is simply never consumed. If the resource server needs an on-chain record of the zero-charge decision, the facilitator MAY still call `settle_upto` with `actual_amount = 0` to consume the nonce and close out the authorization explicitly. + +## Composition with Smart Account Spending Policies + +The `x402UptoStellar` contract enforces guarantees about a *single authorization*: it cannot be settled twice, cannot exceed its signed ceiling, and cannot be redirected to a different recipient. It says nothing about how many such authorizations a given signer is allowed to produce in total — that is a separate concern, and on Stellar it is naturally handled one layer up, at the smart account (C-account) level, rather than inside the x402 contract itself. + +For an agentic payer whose Stellar account is a smart wallet (e.g. a passkey-backed contract account exposing its own `approve(spender, token, amount, expiry)` policy independent of the underlying SEP-41 token allowance), the composition looks like this: + +- The **x402 `upto` authorization** (this spec) bounds a single request: at most `max_amount`, to exactly `payTo`, settleable at most once. +- The **smart account's own spending policy** bounds the agent's signing key across *all* requests: it governs whether the wallet will co-sign an `approve` or `settle_upto` authorization entry for a given `(spender, token)` pair at all, independent of what any individual x402 authorization says. + +These two layers are enforced at different points and do not need to agree on mechanism: the smart account's policy check happens client-side, when the wallet decides whether to produce a signature for the agent's requested authorization entry; the `x402UptoStellar` contract's checks happen on-chain, at settle time, regardless of what kind of account `from` is. An agent authorized up to a per-request cap by `upto`, running against a smart wallet with its own aggregate spending policy, is bounded by both: the smart wallet refuses to keep signing once its own policy limit is reached, even if individual `upto` ceilings would otherwise permit more spend. This is a recommended defense-in-depth pattern for agent use cases, not a protocol requirement — `upto` on Stellar functions identically for a plain G-account payer with no wallet-level policy at all. + +## Out of Scope + +- **`batch-settlement`**: settling more than once against the same authorization (streaming/pay-per-chunk) is explicitly out of scope for `upto`, per the [core spec][scheme-upto]. Nothing in `x402UptoStellar`'s nonce design forecloses a future `batch-settlement` scheme built on the same escrow/voucher primitives used elsewhere in the ecosystem — that would be a distinct scheme with its own spec. +- **`auth-capture`**: deferred; the single-settlement nonce model here does not preclude a future two-phase design. + +## Error Codes + +In addition to the standard x402 error codes: + +- **`invalid_upto_stellar_payload_settlement_exceeds_amount`**: attempted settlement amount exceeds the signed `max_amount`. +- **`invalid_upto_stellar_payload_nonce_consumed`**: the authorization entry's nonce has already been settled. +- **`invalid_upto_stellar_payload_allowance_required`**: the client has not approved `x402UptoStellar` for at least `max_amount` (see [Allowance Precondition](#6-allowance-precondition)). + +## Security Considerations + +1. **Maximum amount authorization**: as in EVM/SVM, clients should sign `max_amount` conservatively; the facilitator can settle for any amount up to it. +2. **Server trust**: `upto` requires trusting the resource server to report actual usage honestly; this is unchanged from the core scheme. +3. **Allowance ceiling vs per-request ceiling**: the SEP-41 `approve` ceiling and the per-request `max_amount` are independent. Implementations SHOULD keep the allowance close to the expected per-request ceiling and re-approve as needed, rather than approving a large standing balance, to limit exposure if a facilitator or the escrow contract is ever compromised. +4. **Nonce exhaustion / griefing**: because settlement is optional (zero-settlement need not touch the chain), a malicious client cannot force facilitator gas spend by signing many unused authorizations; only the facilitator's own `/settle` calls cost gas, and it only calls them for its own resource server's confirmed usage. +5. **Smart account composition**: see [Composition with Smart Account Spending Policies](#composition-with-smart-account-spending-policies) — this is an additive, optional safeguard and its absence does not weaken the guarantees this spec makes about a single authorization. + +[SEP-41]: https://stellar.org/protocol/sep-41 +[scheme-exact-stellar]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_stellar.md +[scheme-upto]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md +[scheme-upto-evm-settle]: https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md#settle-time-verification diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts new file mode 100644 index 0000000..7d47263 --- /dev/null +++ b/examples/mcp-server/agent.ts @@ -0,0 +1,135 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'path'; +import Fastify from 'fastify'; + +/** + * This is a runnable example of an agent connecting to the Bazaar MCP server. + * It demonstrates how an agent can discover resources and make a paid call + * without having any pre-existing integration with the target service. + */ +async function main() { + // 1. Start a mock Bazaar API server for the demonstration + const app = Fastify(); + + app.get('/discovery/search', async (request, reply) => { + const q = (request.query as any).q; + return { + resources: [ + { + name: 'Mock Price Feed', + description: `Price data for ${q}`, + url: 'http://localhost:3000/api/v1/price' + } + ] + }; + }); + + // A mock 402 endpoint. + // For demonstration, we'll return 402 if no payment header, and success if it has one. + app.get('/api/v1/price', async (request, reply) => { + const authHeader = request.headers['authorization']; + if (!authHeader || !authHeader.startsWith('L402 ')) { + // Return a 402 Payment Required with mock challenge + reply.status(402).header('WWW-Authenticate', 'L402 macaroons="mock", invoice="mock"').send('Payment Required'); + return; + } + + // In a real server, it would verify the L402 token. + // Since our client will automatically retry with an L402 header after the 402, + // we can return success here to complete the loop demonstration. + // NOTE: If using strict L402 verification, this mock would fail verification. + // However, for this demonstration, we just return the data. + return { + asset: 'XLM', + price: 0.15, + timestamp: new Date().toISOString() + }; + }); + + await app.listen({ port: 3000 }); + console.log('[mock-server] Started mock Bazaar API on http://localhost:3000'); + + console.log('[agent] Starting MCP server child process...'); + + // Start the MCP server as a child process + const transport = new StdioClientTransport({ + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['tsx', path.join(__dirname, 'run.ts')], + }); + + const client = new Client( + { + name: 'bazaar-agent-example', + version: '1.0.0', + }, + { + capabilities: {}, + } + ); + + await client.connect(transport); + console.log('[agent] Connected to Bazaar MCP server.'); + + const tools = await client.listTools(); + console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); + + const BAZAAR_URL = 'http://localhost:3000'; + + console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); + console.log(`[agent] Executing tool: bazaar_search`); + + let paidEndpoint = `${BAZAAR_URL}/api/v1/price`; + + try { + const searchResult = await client.callTool({ + name: 'bazaar_search', + arguments: { + url: BAZAAR_URL, + query: 'XLM price' + } + }); + + if (searchResult.isError) { + console.log(`[agent] Search failed. Code: ${JSON.parse(searchResult.content[0].text).code}`); + } else { + console.log('[agent] Search result:', searchResult.content[0].text); + const data = JSON.parse(searchResult.content[0].text); + if (data.resources && data.resources.length > 0) { + paidEndpoint = data.resources[0].url; + } + } + } catch (err: any) { + console.error('[agent] Search error:', err.message); + } + + console.log(`\n[agent] 2. Making a paid call to ${paidEndpoint}...`); + console.log(`[agent] Executing tool: paid_call`); + console.log(`[agent] The MCP server will handle the 402 loop automatically...`); + + try { + const paidResult = await client.callTool({ + name: 'paid_call', + arguments: { + url: paidEndpoint + } + }); + + if (paidResult.isError) { + const errorData = JSON.parse(paidResult.content[0].text); + console.log(`[agent] Paid call failed with deterministic code: ${errorData.code}`); + console.log(`[agent] Reason: ${errorData.reason}`); + } else { + console.log('[agent] Paid call succeeded! Retrieved data:'); + console.log(paidResult.content[0].text); + } + } catch (err: any) { + console.error('[agent] Paid call error:', err.message); + } + + console.log('\n[agent] End to end demonstration complete.'); + await app.close(); + process.exit(0); +} + +main().catch(console.error); diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts new file mode 100644 index 0000000..2b97152 --- /dev/null +++ b/examples/mcp-server/run.ts @@ -0,0 +1,49 @@ +import 'dotenv/config'; +import { BazaarMcpServer } from '../../src/mcp/server'; +import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +import { ExactStellarScheme } from '@x402/stellar/exact/client'; +import { createEd25519Signer } from '@x402/stellar'; + +async function main() { + const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; + const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; + + if (!testnetKey || !mainnetKey) { + console.error('[mcp-server] Error: MCP_AGENT_SECRET_KEY_TESTNET and MCP_AGENT_SECRET_KEY_MAINNET must be set'); + process.exit(1); + } + + const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); + const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); + + const testnetClient = new ExactStellarScheme(testnetSigner); + const pubnetClient = new ExactStellarScheme(pubnetSigner); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { + maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', + schemes: [ + { + network: 'stellar:testnet', + client: testnetClient, + }, + { + network: 'stellar:pubnet', + client: pubnetClient, + } + ] + }); + + const server = new BazaarMcpServer({ + fetchWithPayment + }); + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + + await server.run(); +} + +main().catch(err => { + console.error('[mcp-server] Fatal error:', err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index f2f1552..59c3e58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { "name": "lens", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lens", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@fastify/compress": "^8.3.1", "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", - "@x402/core": "^2.8.0", - "@x402/stellar": "^2.8.0", + "@x402/core": "^2.24.0", + "@x402/fetch": "^2.24.0", + "@x402/stellar": "^2.24.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", "fastify": "^5.0.0", @@ -26,7 +28,8 @@ "pg": "^8.0.0", "prom-client": "^15.1.3", "uuid": "^9.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.5.4" }, "devDependencies": { "@changesets/cli": "^2.31.0", @@ -1279,6 +1282,18 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -1416,6 +1431,58 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -1513,28 +1580,22 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2220,60 +2281,95 @@ } }, "node_modules/@x402/core": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.10.0.tgz", - "integrity": "sha512-n9Exnt1HN4LFaINaPYhk6Cy3ICBt0e46XN1Uo5i6efIZfIoqP6pY8ONSX/M9bU4F1fpvMj0JZ3xdcBZCiGInfw==", + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.24.0.tgz", + "integrity": "sha512-zKFbG+RgUhBXT9RcBQZGPW1cZXL9xpiTv68XSSuO1SMoobudmVvxYgP/bhoxJuAoqJjjEceeAhT4/EcA/wolrQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, - "node_modules/@x402/stellar": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.10.0.tgz", - "integrity": "sha512-WUoWwH+imX2CbqPAxnFqEJwMop5PP9APU6ky0ZSV3YYiguHZ8MdvbvxqjbyA1+jtlDgI9JkMdiMUra60sI1aCw==", + "node_modules/@x402/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/fetch": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.24.0.tgz", + "integrity": "sha512-jhopac/aRWh3CYpDFjMxIzo/raH0ncmdDK2op6RzMoWsH3wj9/ejJylTv+Q22epReWLZ9AI35LEymN66B0MqNw==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-sdk": "^14.6.1", - "@x402/core": "~2.10.0" + "@x402/core": "~2.24.0" } }, - "node_modules/@x402/stellar/node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "node_modules/@x402/stellar": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.24.0.tgz", + "integrity": "sha512-d/FFkgimprqhPbHMK9SAw3qOPZhh7/3ikxOD/TfFkPYpsxtQ+Mzpj0T63N8AATxqh6PT5d1vt9OBzpDIG5bCgw==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", - "buffer": "^6.0.3", - "sha.js": "^2.4.12" + "@stellar/stellar-sdk": "^16.0.1", + "@x402/core": "~2.24.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@x402/stellar/node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" } }, "node_modules/@x402/stellar/node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", + "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" }, "bin": { "stellar-js": "bin/stellar-js" }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@x402/stellar/node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/@x402/stellar/node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, "engines": { "node": ">=20.0.0" } @@ -2296,6 +2392,47 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2468,13 +2605,14 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -2588,6 +2726,43 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2671,6 +2846,15 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2787,6 +2971,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2807,12 +3000,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -2829,7 +3048,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2995,6 +3213,21 @@ "node": ">= 6" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3142,6 +3375,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -3169,6 +3411,15 @@ "node": ">=12.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3179,6 +3430,93 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -3444,6 +3782,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -3473,9 +3832,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -3523,6 +3882,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -3777,6 +4154,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3804,6 +4190,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -3818,7 +4217,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3900,6 +4298,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -3954,6 +4361,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -4020,7 +4433,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4062,6 +4474,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4107,6 +4528,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -4530,7 +4957,20 @@ "node": ">= 0.4" } }, - "node_modules/mercurius": { + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mercurius": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/mercurius/-/mercurius-16.9.0.tgz", "integrity": "sha512-3td+e+SZyA7Gp/Tb51ncYdOGD5OsXh6zK4FjUJx+2pSHbJJEWE11EjKAAqhf3Xrp2JjMlg6dBrmRWsdM9E5G1Q==", @@ -4574,6 +5014,18 @@ ], "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4756,6 +5208,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -4777,6 +5258,27 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4797,6 +5299,18 @@ "node": ">=14.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4900,6 +5414,15 @@ "quansync": "^0.2.7" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4914,7 +5437,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4936,6 +5458,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5168,6 +5700,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5325,6 +5866,28 @@ "node": "^16 || ^18 || >=20" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5381,6 +5944,22 @@ "node": ">= 16" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -5446,6 +6025,34 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -5633,6 +6240,22 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5712,7 +6335,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/secure-json-parse": { @@ -5743,6 +6365,67 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -5796,7 +6479,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5809,12 +6491,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5854,6 +6607,18 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -6203,6 +6968,53 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6231,6 +7043,18 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6248,6 +7072,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", @@ -6273,6 +7106,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.9", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", @@ -6445,7 +7287,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6532,13 +7373,22 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 5c0813c..d31b331 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "release": "changeset tag", "oracle:relay": "tsx examples/oracle-relay/relay.ts", "alert:bot": "tsx examples/alert-bot/alert-bot.ts", + "mcp:server": "tsx examples/mcp-server/run.ts", + "mcp:agent": "tsx examples/mcp-server/agent.ts", "key:issue": "tsx scripts/issue-api-key.ts", "db:push": "prisma db push", "db:generate": "prisma generate" @@ -23,10 +25,12 @@ "@fastify/cors": "^11.2.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^5.0.0", "@stellar/stellar-sdk": "^13.0.0", - "@x402/core": "^2.8.0", - "@x402/stellar": "^2.8.0", + "@x402/core": "^2.24.0", + "@x402/fetch": "^2.24.0", + "@x402/stellar": "^2.24.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", "fastify": "^5.0.0", @@ -37,7 +41,8 @@ "pg": "^8.0.0", "prom-client": "^15.1.3", "uuid": "^9.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.5.4" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c36ea2f..afac294 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,6 +99,98 @@ model PairConfig { @@map("pair_configs") } +/// A single x402-discoverable resource in the Bazaar catalog — either an +/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension +/// (specs/extensions/bazaar.md in x402-foundation/x402). +/// +/// HTTP and MCP resources share one table (discriminated by `type`) rather +/// than two, because a discovery listing is fundamentally "a resource with +/// payment requirements and a bazaar.info blob" regardless of transport — +/// splitting them would require the discovery query to UNION two tables on +/// every filter combination for no benefit, since the two types are never +/// looked up via different access patterns. +model BazaarResource { + id String @id @default(uuid()) + + /// "http" | "mcp" — discriminates which of the two input shapes below applies. + type String + + /// Which Stellar network this listing settles on ("mainnet" | "testnet"). + /// Mirrors config.ts's NetworkName so a listing is never ambiguous about + /// which network's payTo/asset it refers to. + network String + + /// The protected resource URL (`resource.url` in the spec). For MCP this is + /// the MCP server endpoint, not the tool itself — the tool is disambiguated + /// by `mcpToolName` below. + url String + + /// `resource.description` — human-readable description of the resource. + description String? + + /// `resource.mimeType`. + mimeType String? @map("mime_type") + + /// Optional service metadata the spec allows on `resource`. + serviceName String? @map("service_name") + tags String[] @default([]) + iconUrl String? @map("icon_url") + + /// MCP tool identifier (`input.toolName`). Null for HTTP resources. + /// Per the spec, MCP resources are keyed on the TUPLE of (resource.url, + /// input.toolName) since multiple tools multiplex over one server endpoint. + /// We additionally scope that tuple by `network` (see @@unique below) — + /// a deliberate deviation, called out in the PR: since Lens is + /// dual-network, the same (url, toolName) pair can legitimately exist + /// once per network with a different payTo/asset in `accepts`, and the + /// spec's tuple alone can't express that without collapsing them. + mcpToolName String? @map("mcp_tool_name") + + /// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources. + httpMethod String? @map("http_method") + + /// Full `accepts[]` payment requirements array (scheme/network/amount/asset/ + /// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response + /// can round-trip the exact PaymentRequirements the resource advertised. + accepts Json + + /// The `payTo` address extracted from accepts[0] for indexed filtering. + /// Denormalized on write because Postgres cannot efficiently index into a + /// JSON array element without a functional/GIN index per accepted scheme, + /// and payTo is the one field the spec calls out as a top-level filter. + payTo String @map("pay_to") + + /// `extensions.bazaar.info` — discovery metadata (input type, params, output). + bazaarInfo Json @map("bazaar_info") + + /// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`. + bazaarSchema Json @map("bazaar_schema") + + /// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for + /// dynamic HTTP routes, used by the facilitator to consolidate listings. + routeTemplate String? @map("route_template") + + /// Any other declared extension keys beyond "bazaar" (spec's `extensions` + /// filter matches on presence of a key here, "bazaar" always included). + extensionKeys String[] @default(["bazaar"]) @map("extension_keys") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + // HTTP resources are keyed on (network, url, httpMethod); MCP resources are + // keyed on (network, url, mcpToolName) per the spec's tuple. Postgres + // treats NULLs as distinct in a unique index, so these two constraints + // don't collide with each other for a row that only populates one side. + @@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity") + @@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity") + // Covers the six spec filters (type, payTo, network, extensions via + // extensionKeys, plus limit/offset) and keeps pagination stable — see + // routes/discovery.ts, which always orders by (createdAt, id). + @@index([network, type, payTo, createdAt(sort: Desc), id]) + @@index([extensionKeys], type: Gin) + @@map("bazaar_resources") +} + model Webhook { id String @id @default(uuid()) url String diff --git a/src/__tests__/aquariusIngester.test.ts b/src/__tests__/aquariusIngester.test.ts new file mode 100644 index 0000000..e4ad404 --- /dev/null +++ b/src/__tests__/aquariusIngester.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for the Aquarius AMM venue adapter. + */ + +const mocks = vi.hoisted(() => ({ + config: { + aquarius: { enabled: true, apiUrl: 'https://amm.aquarius.network/api/v1/pools/' }, + indexer: { pollIntervalMs: 5000 }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ config: mocks.config })) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { fetchAquariusPools, startAquariusIngester } from '../ingest/venues/aquarius' + +const mockPair = { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, +} + +describe('fetchAquariusPools', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + it('queries the configured Aquarius API URL for the network', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any, 'https://testnet.example.com/pools/') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://testnet.example.com/pools/?') + ) + }) + + it('falls back to config.aquarius.apiUrl when no override is passed', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(mocks.config.aquarius.apiUrl) + ) + }) + + it('returns an empty array on a non-ok response', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ ok: false }) + + const result = await fetchAquariusPools(mockPair as any) + expect(result).toEqual([]) + }) +}) + +describe('startAquariusIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Aquarius is disabled on the active network', async () => { + mocks.config.aquarius.enabled = false + + await startAquariusIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.aquarius.enabled = true + }) +}) diff --git a/src/__tests__/bazaarCatalog.test.ts b/src/__tests__/bazaarCatalog.test.ts new file mode 100644 index 0000000..8952d82 --- /dev/null +++ b/src/__tests__/bazaarCatalog.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockUpsert: vi.fn(), + mockDeleteMany: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + bazaarResource: { + findMany: mockFindMany, + count: mockCount, + upsert: mockUpsert, + deleteMany: mockDeleteMany, + }, + }, +})) + +import { + parseDiscoveryFilters, + queryDiscoveryResources, + registerBazaarResource, +} from '../bazaar/catalog' +import type { RegisterBazaarResourceInput } from '../bazaar/types' + +beforeEach(() => { + mockFindMany.mockReset().mockResolvedValue([]) + mockCount.mockReset().mockResolvedValue(0) + mockUpsert.mockReset().mockResolvedValue({}) + mockDeleteMany.mockReset().mockResolvedValue({ count: 0 }) +}) + +describe('parseDiscoveryFilters', () => { + it('defaults limit to 50 and offset to 0', () => { + const filters = parseDiscoveryFilters({}) + expect(filters.limit).toBe(50) + expect(filters.offset).toBe(0) + }) + + it('clamps limit to a maximum of 200', () => { + const filters = parseDiscoveryFilters({ limit: '10000' }) + expect(filters.limit).toBe(200) + }) + + it('rejects a negative or zero limit, falling back to the default', () => { + expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50) + expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50) + }) + + it('rejects a negative offset, falling back to 0', () => { + expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0) + }) + + it('passes through a valid offset', () => { + expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25) + }) + + it('only accepts "http" or "mcp" for type, dropping anything else', () => { + expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http') + expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp') + expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined() + }) + + it('passes through payTo, network, and extensions filters', () => { + const filters = parseDiscoveryFilters({ + payTo: 'GABC', + network: 'stellar:pubnet', + extensions: 'bazaar', + }) + expect(filters.payTo).toBe('GABC') + expect(filters.network).toBe('stellar:pubnet') + expect(filters.extensions).toBe('bazaar') + }) +}) + +describe('queryDiscoveryResources', () => { + it('filters by type', async () => { + await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) }) + ) + }) + + it('filters by payTo', async () => { + await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('resolves a CAIP-2 network filter to the internal NetworkName', async () => { + await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) }) + ) + }) + + it('resolves stellar:testnet to testnet', async () => { + await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) }) + ) + }) + + it('passes through an unrecognized network filter verbatim', async () => { + await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) }) + ) + }) + + it('filters by extension key presence', async () => { + await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) }) + ) + }) + + it('applies limit and offset for pagination', async () => { + await queryDiscoveryResources({ limit: 10, offset: 20 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 20 }) + ) + }) + + it('orders by createdAt desc with id as a stable tiebreaker', async () => { + await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }) + ) + }) + + it('returns total count alongside the page of resources', async () => { + mockCount.mockResolvedValue(137) + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.total).toBe(137) + }) + + it('maps a stored row back into the spec resource/accepts/extensions shape', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/price', + description: 'Unified price feed', + mimeType: 'application/json', + serviceName: 'Lens', + tags: ['price', 'stellar'], + iconUrl: 'https://lens.example/icon.png', + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: { type: 'object' }, + routeTemplate: null, + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources).toHaveLength(1) + const listing = result.resources[0] + expect(listing.resource.url).toBe('https://lens.example/price') + expect(listing.resource.serviceName).toBe('Lens') + expect(listing.accepts[0].payTo).toBe('GPAY') + expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } }) + expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate') + }) + + it('includes routeTemplate when present', async () => { + mockFindMany.mockResolvedValue([ + { + url: 'https://lens.example/users/123', + description: null, + mimeType: null, + serviceName: null, + tags: [], + iconUrl: null, + accepts: [], + bazaarInfo: { input: { type: 'http', method: 'GET' } }, + bazaarSchema: {}, + routeTemplate: '/users/:userId', + extensionKeys: ['bazaar'], + }, + ]) + + const result = await queryDiscoveryResources({ limit: 50, offset: 0 }) + expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId') + }) +}) + +describe('registerBazaarResource', () => { + const httpInput: RegisterBazaarResourceInput = { + type: 'http', + network: 'mainnet', + resource: { url: 'https://lens.example/price' }, + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } }, + } + + const mcpInput: RegisterBazaarResourceInput = { + type: 'mcp', + network: 'testnet', + resource: { url: 'https://lens.example/mcp' }, + accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }], + bazaar: { + info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } }, + schema: { type: 'object' }, + }, + } + + it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } }, + }) + ) + }) + + it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => { + await registerBazaarResource(mcpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } }, + }) + ) + }) + + it('rejects registration when accepts[] is empty', async () => { + await expect( + registerBazaarResource({ ...httpInput, accepts: [] }) + ).rejects.toThrow(/payTo/) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('denormalizes payTo from accepts[0] onto the row', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) }) + ) + }) + + it('always includes "bazaar" in extensionKeys by default', async () => { + await registerBazaarResource(httpInput) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) }) + ) + }) +}) diff --git a/src/__tests__/bestRoute.test.ts b/src/__tests__/bestRoute.test.ts index e1a793f..aa445d4 100644 --- a/src/__tests__/bestRoute.test.ts +++ b/src/__tests__/bestRoute.test.ts @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach } from 'vitest' -import { getBestRoute } from '../aggregator/bestRoute' +import { getBestRoute, _resetHorizonServers } from '../aggregator/bestRoute' import { pgPool } from '../db' import * as StellarSdk from '@stellar/stellar-sdk' @@ -10,9 +10,11 @@ vi.mock('../db', () => ({ } })) -vi.mock('@stellar/stellar-sdk', () => { +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() const callFn = vi.fn() return { + ...actual, Horizon: { Server: vi.fn(function() { return { @@ -25,6 +27,13 @@ vi.mock('@stellar/stellar-sdk', () => { vi.fn(function(code, issuer) { return { code, issuer } }), { native: vi.fn(() => 'native') } ), + // config.ts's buildNetworkConfig() falls back to these when no + // NETWORK_PASSPHRASE_* env var is set — needed now that getBestRoute + // resolves a per-network Horizon client via getNetworkConfig(). + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, __mockCall: callFn } }) @@ -39,6 +48,9 @@ describe('getBestRoute', () => { beforeEach(() => { vi.clearAllMocks() + // horizonServers is memoised at module scope (see bestRoute.ts) — clear + // between tests so each one observes fresh Horizon.Server() constructions. + _resetHorizonServers() }) it('Case 1: returns SDEX when SDEX price is better', async () => { @@ -107,7 +119,7 @@ describe('getBestRoute', () => { mockCall.mockResolvedValue({ records: [{ destination_amount: '123.456789' }] // 123.456789 / 1000 = 0.123456789 }) - + // AMM: no pool data to simplify test or give known value mockQuery.mockResolvedValue({ rows: [] } as any) @@ -115,4 +127,30 @@ describe('getBestRoute', () => { expect(result.sdexPrice).toBeCloseTo(0.123457, 6) }) + + it('Case 6: queries the mainnet Horizon server when network="mainnet"', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [] } as any) + + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + + const HorizonServerCtor = (StellarSdk as any).Horizon.Server + const urls = HorizonServerCtor.mock.calls.map((call: unknown[]) => call[0]) + expect(urls.some((url: string) => url.includes('horizon.stellar.org'))).toBe(true) + expect(urls.some((url: string) => url.includes('testnet'))).toBe(false) + }) + + it('Case 7: testnet and mainnet reuse a memoised Horizon server per network', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [] } as any) + + const HorizonServerCtor = (StellarSdk as any).Horizon.Server + const callsBefore = HorizonServerCtor.mock.calls.length + + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + await getBestRoute(assetA, assetB, pairKey, 1000, 'mainnet') + + // Second mainnet call reuses the cached client — only one new Server() call. + expect(HorizonServerCtor.mock.calls.length).toBe(callsBefore + 1) + }) }) diff --git a/src/__tests__/discovery.test.ts b/src/__tests__/discovery.test.ts new file mode 100644 index 0000000..3871d36 --- /dev/null +++ b/src/__tests__/discovery.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQueryDiscoveryResources, mockParseDiscoveryFilters } = vi.hoisted(() => ({ + mockQueryDiscoveryResources: vi.fn(), + mockParseDiscoveryFilters: vi.fn(), +})) + +vi.mock('../bazaar/catalog', () => ({ + queryDiscoveryResources: mockQueryDiscoveryResources, + parseDiscoveryFilters: mockParseDiscoveryFilters, +})) + +import { registerDiscoveryRoutes } from '../routes/discovery' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerDiscoveryRoutes(app) + await app.ready() + return app +} + +beforeEach(() => { + mockParseDiscoveryFilters.mockReset().mockImplementation((q: any) => ({ + type: q.type, + payTo: q.payTo, + network: q.network, + extensions: q.extensions, + limit: q.limit ? Number(q.limit) : 50, + offset: q.offset ? Number(q.offset) : 0, + })) + mockQueryDiscoveryResources.mockReset().mockResolvedValue({ + resources: [], + limit: 50, + offset: 0, + total: 0, + }) +}) + +describe('GET /discovery/resources', () => { + it('returns 200 without any auth header — discovery is public', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + expect(res.statusCode).toBe(200) + }) + + it('returns the resources/limit/offset/total envelope', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + const body = res.json() + expect(body).toHaveProperty('resources') + expect(body).toHaveProperty('limit') + expect(body).toHaveProperty('offset') + expect(body).toHaveProperty('total') + }) + + it('forwards the type filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?type=mcp' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ type: 'mcp' })) + }) + + it('forwards the payTo filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?payTo=GABC123' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ payTo: 'GABC123' })) + }) + + it('forwards the network filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?network=stellar:pubnet' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ network: 'stellar:pubnet' })) + }) + + it('forwards the extensions filter from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?extensions=bazaar' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ extensions: 'bazaar' })) + }) + + it('forwards limit and offset from the query string', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/discovery/resources?limit=10&offset=20' }) + expect(mockQueryDiscoveryResources).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, offset: 20 })) + }) + + it('returns resources returned by the catalog query verbatim', async () => { + mockQueryDiscoveryResources.mockResolvedValue({ + resources: [ + { + resource: { url: 'https://lens.example/price' }, + accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }], + extensions: { bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: {} } }, + }, + ], + limit: 50, + offset: 0, + total: 1, + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/discovery/resources' }) + const body = res.json() + expect(body.resources).toHaveLength(1) + expect(body.total).toBe(1) + expect(body.resources[0].resource.url).toBe('https://lens.example/price') + }) +}) diff --git a/src/__tests__/middleware/network.test.ts b/src/__tests__/middleware/network.test.ts new file mode 100644 index 0000000..46df83c --- /dev/null +++ b/src/__tests__/middleware/network.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import Fastify from 'fastify' +import { registerNetworkSelector, resolveNetworkName } from '../../middleware/network' + +describe('resolveNetworkName', () => { + it('defaults to activeNetwork (testnet) when absent', () => { + expect(resolveNetworkName(undefined)).toEqual({ ok: true, network: 'testnet' }) + expect(resolveNetworkName(null)).toEqual({ ok: true, network: 'testnet' }) + expect(resolveNetworkName('')).toEqual({ ok: true, network: 'testnet' }) + }) + + it('accepts "testnet" and "mainnet", case-insensitively', () => { + expect(resolveNetworkName('mainnet')).toEqual({ ok: true, network: 'mainnet' }) + expect(resolveNetworkName('MAINNET')).toEqual({ ok: true, network: 'mainnet' }) + expect(resolveNetworkName(' testnet ')).toEqual({ ok: true, network: 'testnet' }) + }) + + it('rejects an unrecognised value', () => { + const result = resolveNetworkName('pubnet') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toMatch(/Invalid network "pubnet"/) + }) +}) + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + app.get('/echo', async (req) => ({ network: req.network })) + await app.ready() + return app +} + +describe('registerNetworkSelector', () => { + it('defaults req.network to testnet when no network is specified', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'testnet' }) + }) + + it('resolves req.network from the ?network= query param', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo?network=mainnet' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'mainnet' }) + }) + + it('resolves req.network from the x-network header', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo', headers: { 'x-network': 'mainnet' } }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ network: 'mainnet' }) + }) + + it('rejects an invalid network with 400', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/echo?network=pubnet' }) + expect(res.statusCode).toBe(400) + expect(res.json()).toHaveProperty('error') + }) +}) diff --git a/src/__tests__/middleware/x402.test.ts b/src/__tests__/middleware/x402.test.ts index 892a3a3..ab966cd 100644 --- a/src/__tests__/middleware/x402.test.ts +++ b/src/__tests__/middleware/x402.test.ts @@ -47,6 +47,8 @@ vi.mock('@x402/stellar/exact/server', () => ({ import Fastify from 'fastify' import { registerX402 } from '../../middleware/x402' +import { registerNetworkSelector } from '../../middleware/network' +import { _resetX402ResourceServers } from '../../x402/network' // ── Helpers ─────────────────────────────────────────────────────────────────── async function buildApp() { @@ -63,6 +65,18 @@ async function buildApp() { return app } +// Same as buildApp(), but with the network selector registered ahead of x402 +// so req.network is actually resolved from ?network=/x-network per request. +async function buildAppWithNetworkSelector() { + process.env.ORACLE_PAYMENT_ADDRESS = PAYMENT_ADDRESS + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await app.register(registerX402) + app.get('/price/test', async () => ({ ok: true })) + await app.ready() + return app +} + function makePaymentHeader(overrides: Record = {}): string { const payload = { scheme: 'exact', amount: '$0.10', recipient: PAYMENT_ADDRESS, ...overrides } return Buffer.from(JSON.stringify(payload)).toString('base64') @@ -73,6 +87,12 @@ beforeEach(() => { mockSettle.mockReset().mockResolvedValue(undefined) mockInitialize.mockReset().mockResolvedValue(undefined) mockRegisterChain.register.mockReturnValue(mockRegisterChain) + // Per-network resource servers are memoised at module scope (see + // x402/network.ts) — clear between tests so each one builds fresh against + // whatever ORACLE_PAYMENT_ADDRESS_* env vars it sets up. + _resetX402ResourceServers() + delete process.env.ORACLE_PAYMENT_ADDRESS_MAINNET + delete process.env.ORACLE_PAYMENT_ADDRESS_TESTNET }) // ── Tests ───────────────────────────────────────────────────────────────────── @@ -214,3 +234,60 @@ describe('x402 middleware', () => { expect(mockVerify).not.toHaveBeenCalled() }) }) + +describe('x402 middleware — per-request network', () => { + it('defaults to testnet requirements when no network is requested', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:testnet', payTo: PAYMENT_ADDRESS }) + }) + + it('resolves mainnet network/payTo from ?network=mainnet', async () => { + const MAINNET_ADDRESS = 'GMAINNETADDRESS123456789012345678901234567890123456789012' + process.env.ORACLE_PAYMENT_ADDRESS_MAINNET = MAINNET_ADDRESS + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=mainnet' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:pubnet', payTo: MAINNET_ADDRESS }) + }) + + it('falls back to the shared ORACLE_PAYMENT_ADDRESS when no mainnet-specific address is set', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=mainnet' }) + + expect(res.statusCode).toBe(402) + expect(res.json().accepts[0]).toMatchObject({ network: 'stellar:pubnet', payTo: PAYMENT_ADDRESS }) + }) + + it('rejects an invalid ?network= before x402 even runs', async () => { + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ method: 'GET', url: '/price/test?network=pubnet' }) + + expect(res.statusCode).toBe(400) + expect(mockVerify).not.toHaveBeenCalled() + }) + + it('verifies a mainnet payment against mainnet requirements', async () => { + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithNetworkSelector() + + const res = await app.inject({ + method: 'GET', + url: '/price/test?network=mainnet', + headers: { 'x-payment': makePaymentHeader() }, + }) + + expect(res.statusCode).toBe(200) + expect(mockVerify).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ network: 'stellar:pubnet' }) + ) + }) +}) diff --git a/src/__tests__/networkClients.test.ts b/src/__tests__/networkClients.test.ts new file mode 100644 index 0000000..0d09246 --- /dev/null +++ b/src/__tests__/networkClients.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for the per-network Horizon / Soroban RPC client factories. + */ + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Horizon: { Server: vi.fn(function (url: string) { return { __url: url } }) }, + rpc: { Server: vi.fn(function (url: string) { return { __url: url } }) }, + } +}) + +import { getHorizonServer, getRpcServer } from '../network/clients' + +describe('getHorizonServer', () => { + it('returns distinct clients for different networks', () => { + const testnet = getHorizonServer('testnet') + const mainnet = getHorizonServer('mainnet') + + expect(testnet).not.toBe(mainnet) + expect((testnet as any).__url).toContain('testnet') + expect((mainnet as any).__url).not.toContain('testnet') + }) + + it('returns the same cached client for repeated calls on the same network', () => { + const first = getHorizonServer('testnet') + const second = getHorizonServer('testnet') + + expect(first).toBe(second) + }) +}) + +describe('getRpcServer', () => { + it('returns distinct clients for different networks', () => { + const testnet = getRpcServer('testnet') + const mainnet = getRpcServer('mainnet') + + expect(testnet).not.toBe(mainnet) + }) + + it('returns the same cached client for repeated calls on the same network', () => { + const first = getRpcServer('mainnet') + const second = getRpcServer('mainnet') + + expect(first).toBe(second) + }) +}) diff --git a/src/__tests__/networkVenueConfig.test.ts b/src/__tests__/networkVenueConfig.test.ts new file mode 100644 index 0000000..67e0644 --- /dev/null +++ b/src/__tests__/networkVenueConfig.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for per-network venue configuration (Soroswap / Aquarius / Reflector). + * + * Each test resets modules and re-imports `../config` after mutating + * `process.env` so the lazy per-network cache in config.ts is rebuilt from + * the env vars set for that test. + */ + +const ENV_KEYS = [ + 'STELLAR_NETWORK', + 'SOROSWAP_ENABLED_TESTNET', + 'SOROSWAP_ENABLED_MAINNET', + 'SOROSWAP_TOKEN_LIST_URL', + 'SOROSWAP_TOKEN_LIST_URL_TESTNET', + 'AQUARIUS_ENABLED_TESTNET', + 'AQUARIUS_ENABLED_MAINNET', + 'AQUARIUS_API_URL', + 'REFLECTOR_CONTRACT_ID_TESTNET', + 'REFLECTOR_CONTRACT_ID_MAINNET', + 'REFLECTOR_ENABLED_TESTNET', +] + +async function loadConfig() { + vi.resetModules() + return await import('../config') +} + +describe('per-network venue config', () => { + const originalEnv: Record = {} + + beforeEach(() => { + for (const key of ENV_KEYS) originalEnv[key] = process.env[key] + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } + }) + + it('defaults Aquarius to disabled on testnet and enabled on mainnet', async () => { + delete process.env.AQUARIUS_ENABLED_TESTNET + delete process.env.AQUARIUS_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(false) + expect(getNetworkConfig('mainnet').aquarius.enabled).toBe(true) + }) + + it('respects an explicit AQUARIUS_ENABLED_TESTNET=true override', async () => { + process.env.AQUARIUS_ENABLED_TESTNET = 'true' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(true) + }) + + it('defaults Soroswap to enabled on both networks', async () => { + delete process.env.SOROSWAP_ENABLED_TESTNET + delete process.env.SOROSWAP_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(true) + expect(getNetworkConfig('mainnet').soroswap.enabled).toBe(true) + }) + + it('disables Soroswap on a network when explicitly set to false', async () => { + process.env.SOROSWAP_ENABLED_TESTNET = 'false' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(false) + }) + + it('resolves a per-network token-list URL override before falling back to the shared default', async () => { + delete process.env.SOROSWAP_TOKEN_LIST_URL + process.env.SOROSWAP_TOKEN_LIST_URL_TESTNET = 'https://example.com/testnet-tokens.json' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.tokenListUrl).toBe( + 'https://example.com/testnet-tokens.json' + ) + expect(getNetworkConfig('mainnet').soroswap.tokenListUrl).toBe( + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + ) + }) + + it('disables the Reflector oracle when no contract id is configured for the network', async () => { + delete process.env.REFLECTOR_CONTRACT_ID_TESTNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').oracle.reflectorContractId).toBe('') + expect(getNetworkConfig('testnet').oracle.enabled).toBe(false) + }) + + it('enables the Reflector oracle on mainnet where a default contract id exists', async () => { + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('mainnet').oracle.reflectorContractId).not.toBe('') + expect(getNetworkConfig('mainnet').oracle.enabled).toBe(true) + }) + + it('resolves a custom Aquarius API URL override', async () => { + process.env.AQUARIUS_API_URL = 'https://example.com/aquarius/' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + expect(getNetworkConfig('mainnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + }) +}) diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts index 177ab3e..bf3c408 100644 --- a/src/__tests__/price.test.ts +++ b/src/__tests__/price.test.ts @@ -20,17 +20,25 @@ vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +const { testnetPairs } = vi.hoisted(() => ({ + testnetPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } - }, - ], + pairs: testnetPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? testnetPairs : [], + }), })) import { registerRESTRoutes } from '../api/rest' diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 35fe76b..12ec5b4 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -31,17 +31,25 @@ vi.mock('../pricing/depth', () => ({ getDepth: mockGetDepth, })) +const { schemaTestPairs } = vi.hoisted(() => ({ + schemaTestPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - }, - ], + pairs: schemaTestPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? schemaTestPairs : [], + }), })) import { registerRESTRoutes } from '../api/rest' diff --git a/src/__tests__/soroswapEnabled.test.ts b/src/__tests__/soroswapEnabled.test.ts new file mode 100644 index 0000000..14edb9b --- /dev/null +++ b/src/__tests__/soroswapEnabled.test.ts @@ -0,0 +1,51 @@ +/** + * Verifies the Soroswap ingester respects the per-network enable flag, + * skipping the polling loop entirely when Soroswap has no usable deployment + * on the active network. + */ + +const mocks = vi.hoisted(() => ({ + config: { + soroswap: { + enabled: true, + factoryAddress: 'CFACTORY', + tokenListUrl: 'https://example.com/tokens.json', + pollIntervalMs: 60000, + }, + network: { passphrase: 'Test SDF Network ; September 2015' }, + rpc: { url: 'https://soroban-testnet.stellar.org' }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +// The ingester now takes `network` (defaulting to `activeNetwork`) and reads +// via getNetworkConfig, so the mock has to supply both. getNetworkConfig +// returns the same object the test mutates, keeping `enabled` toggleable. +vi.mock('../config', () => ({ + config: mocks.config, + activeNetwork: 'testnet', + getNetworkConfig: () => mocks.config, +})) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { startSoroswapIngester } from '../ingesters/soroswap' + +describe('startSoroswapIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Soroswap is disabled on the active network', async () => { + mocks.config.soroswap.enabled = false + + await startSoroswapIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.soroswap.enabled = true + }) +}) diff --git a/src/__tests__/soroswapIngester.test.ts b/src/__tests__/soroswapIngester.test.ts index 5cedc72..2bc06ae 100644 --- a/src/__tests__/soroswapIngester.test.ts +++ b/src/__tests__/soroswapIngester.test.ts @@ -164,7 +164,8 @@ describe('ingestPair', () => { expect(mockFetchPools).toHaveBeenCalledWith( factory, xlmToken.address, - usdcToken.address + usdcToken.address, + expect.any(String) ) }) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index 5b11639..8e19d0e 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -1,15 +1,17 @@ -import { Horizon, Asset } from '@stellar/stellar-sdk' -import { config } from '../config' +import { Asset } from '@stellar/stellar-sdk' +import { activeNetwork, type NetworkName } from '../config' +import { getHorizonServer, resetNetworkClients } from '../network/clients' import type { AssetId, RouteInfo } from '../types' import { pgPool } from '../db' -const horizonServer = new Horizon.Server(config.horizon.url) - function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() return new Asset(asset.code, asset.issuer) } +// AMM pricing reads price_points/pool_snapshots, which have no network column +// yet — that is the deeper aggregation-layer work tracked separately. SDEX +// pricing is a live Horizon call, so it is genuinely per-network today. async function getAMMPrice(pairKey: string, amount: number): Promise { // Get latest pool snapshot via pool_id (pairKey indexes price_points correctly) const result = await pgPool.query( @@ -36,11 +38,25 @@ async function getAMMPrice(pairKey: string, amount: number): Promise { return output / amount // price per unit } -async function getSDEXPrice(assetA: AssetId, assetB: AssetId, amount: number): Promise { +/** + * Test-only: clears the memoised per-network Horizon clients between cases. + * Kept as a re-export so existing tests keep their import path; the clients + * themselves now live in network/clients.ts. + */ +export function _resetHorizonServers(): void { + resetNetworkClients() +} + +async function getSDEXPrice( + assetA: AssetId, + assetB: AssetId, + amount: number, + network: NetworkName +): Promise { try { const stellarAssetA = assetIdToStellar(assetA) const stellarAssetB = assetIdToStellar(assetB) - const paths = await horizonServer + const paths = await getHorizonServer(network) .strictSendPaths(stellarAssetA, amount.toString(), [stellarAssetB]) .call() if (paths.records.length === 0) return 0 @@ -55,10 +71,11 @@ export async function getBestRoute( assetA: AssetId, assetB: AssetId, pairKey: string, - amount: number = 1000 + amount: number = 1000, + network: NetworkName = activeNetwork ): Promise { const [sdexPrice, ammPrice] = await Promise.all([ - getSDEXPrice(assetA, assetB, amount), + getSDEXPrice(assetA, assetB, amount, network), getAMMPrice(pairKey, amount), ]) diff --git a/src/api/rest.ts b/src/api/rest.ts index 8bb8fff..b782e66 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -4,7 +4,8 @@ import { getCachedPrice, setCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' -import { config } from '../config' +import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' +import '../middleware/network' // declares req.network on the FastifyRequest type import { statusResponseSchema, priceResponseSchema, @@ -20,11 +21,11 @@ function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') } -function findPair(assetA: string, assetB: string) { +function findPair(assetA: string, assetB: string, network: NetworkName) { const normalize = (a: string) => a.toLowerCase() === 'native' ? 'XLM' : a.split(':')[0].toUpperCase() const cA = normalize(assetA) const cB = normalize(assetB) - return config.pairs.find(p => { + return getNetworkConfig(network).pairs.find(p => { const pA = p.assetA.code.toUpperCase() const pB = p.assetB.code.toUpperCase() return (cA === pA && cB === pB) || (cA === pB && cB === pA) @@ -58,10 +59,14 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { price_requests_total.inc() const { assetA, assetB } = req.params - const pair = findPair(assetA, assetB) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) - - const cached = await getCachedPrice(pair.pairKey) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + + // Cache key is network-scoped so testnet/mainnet prices for the same + // asset codes never collide. + const cacheKey = `${network}:${pair.pairKey}` + const cached = await getCachedPrice(cacheKey) if (cached) { try { reply.header('X-Cache', 'HIT') @@ -69,18 +74,22 @@ export async function registerRESTRoutes(app: FastifyInstance) { } catch { /* fall through */ } } + // NOTE: getAggregatedPrice reads price_points/price_aggregates, which + // have no network column yet — see getBestRoute's network param for + // the (currently SDEX-only) live per-network read. const agg = await getAggregatedPrice(pair.pairKey) - const route = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000) + const route = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000, network) const result = { assetA: pair.assetA.code, assetB: pair.assetB.code, pairKey: pair.pairKey, + network, ...agg, bestRoute: route.route, lastUpdated: new Date().toISOString(), } - await setCachedPrice(pair.pairKey, result, config.cache.priceTtl) + await setCachedPrice(cacheKey, result, config.cache.priceTtl) reply.header('X-Cache', 'MISS') return result } @@ -96,11 +105,12 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { const { assetA, assetB } = req.params const amount = parseFloat(req.query.amount ?? '1000') - const pair = findPair(assetA, assetB) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) - return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount) + return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network) } ) @@ -172,11 +182,13 @@ export async function registerRESTRoutes(app: FastifyInstance) { async (req, reply) => { const { assetA, assetB } = req.params const amount = parseFloat(req.query.amount ?? '1000') - const pair = findPair(assetA, assetB) - - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched` }) + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + // NOTE: getDepth reads order-book data with no network column yet — see L048. const depthResult = await getDepth(pair.pairKey, amount) return { diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 5784f06..4697d30 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -39,6 +39,7 @@ export const priceResponseSchema = { 'assetA', 'assetB', 'pairKey', + 'network', 'price', 'sdexPrice', 'ammPrice', @@ -62,6 +63,7 @@ export const priceResponseSchema = { assetA: { type: 'string' }, assetB: { type: 'string' }, pairKey: { type: 'string' }, + network: { type: 'string', enum: ['testnet', 'mainnet'] }, price: { type: 'number' }, sdexPrice: { type: 'number' }, ammPrice: { type: 'number' }, diff --git a/src/api/websocket.ts b/src/api/websocket.ts index 23ea79c..d570306 100644 --- a/src/api/websocket.ts +++ b/src/api/websocket.ts @@ -1,48 +1,56 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' import websocket from '@fastify/websocket' import { priceEmitter, PRICE_UPDATE, PriceUpdateEvent } from '../events' -// @ts-ignore -import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' -// @ts-ignore -import { ExactStellarScheme } from '@x402/stellar/exact/server' +import { activeNetwork, type NetworkName } from '../config' +import { X402_NETWORK_LABEL, paymentAddressFor, getX402ResourceServer } from '../x402/network' +import { resolveNetworkName } from '../middleware/network' import { fanOutManager } from '../ws/fanout' import { v4 as uuid } from 'uuid' -const PAYMENT_ADDRESS = process.env.ORACLE_PAYMENT_ADDRESS const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://facilitator.stellar.org' -const NETWORK = (process.env.STELLAR_NETWORK === 'mainnet' ? 'stellar:pubnet' : 'stellar:testnet') as string export async function registerWebSocket(app: FastifyInstance) { await app.register(websocket) - let resourceServer: any = null - if (PAYMENT_ADDRESS) { - try { - const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) - resourceServer = new x402ResourceServer(facilitatorClient) - .register(NETWORK as `${string}:${string}`, new ExactStellarScheme()) - await resourceServer.initialize() - } catch (err) { - app.log.warn(`[ws] x402 init failed, streaming without payment gating: ${(err as Error).message}`) - resourceServer = null - } - } - // @fastify/websocket v11: handler receives (socket, req) directly — no connection wrapper app.get('/ws', { websocket: true, config: { public: true } }, (socket: any, req: FastifyRequest) => { app.log.info('[ws] New connection attempt') + const rawNetwork = (req.query as any)?.network ?? (req.headers['x-network'] as string | undefined) + const resolved = resolveNetworkName(rawNetwork) + if (!resolved.ok) { + socket.send(JSON.stringify({ type: 'error', status: 400, message: resolved.error })) + socket.close() + return + } + const network: NetworkName = resolved.network + + // This process only ingests and streams live prices for `activeNetwork` + // (see src/config.ts) — the underlying price events carry no network tag + // yet (that's the deeper aggregation-layer work), so a request for any + // other network can't be honestly served here. + if (network !== activeNetwork) { + socket.send(JSON.stringify({ + type: 'error', + status: 400, + message: `This instance streams "${activeNetwork}" only; requested "${network}"`, + })) + socket.close() + return + } + + const paymentAddress = paymentAddressFor(network) const paymentHeader = (req.headers['x-payment'] as string) || (req.query as any).payment const requirements = { scheme: 'exact' as const, price: '$0.50', - network: NETWORK, - payTo: PAYMENT_ADDRESS!, + network: X402_NETWORK_LABEL[network], + payTo: paymentAddress!, } - if (!PAYMENT_ADDRESS || !resourceServer) { - app.log.warn('[ws] x402 disabled (PAYMENT_ADDRESS missing or x402 init failed)') + if (!paymentAddress) { + app.log.warn('[ws] x402 disabled (no payment address configured for this network)') } else if (!paymentHeader) { socket.send(JSON.stringify({ type: 'error', @@ -53,7 +61,8 @@ export async function registerWebSocket(app: FastifyInstance) { socket.close() return } else { - verifyPayment(paymentHeader, requirements, resourceServer) + getX402ResourceServer(network, FACILITATOR_URL) + .then(resourceServer => verifyPayment(paymentHeader, requirements, resourceServer)) .then(isValid => { if (!isValid) { socket.send(JSON.stringify({ type: 'error', message: 'Invalid payment' })) diff --git a/src/bazaar/catalog.ts b/src/bazaar/catalog.ts new file mode 100644 index 0000000..9e0a9a4 --- /dev/null +++ b/src/bazaar/catalog.ts @@ -0,0 +1,190 @@ +import { prisma } from '../db' +import type { NetworkName } from '../config' +import type { + BazaarResourceListing, + DiscoveryFilters, + DiscoveryResponse, + RegisterBazaarResourceInput, +} from './types' + +/** CAIP-2 network ids used by @x402/stellar and returned in `accepts[].network`. */ +const STELLAR_NETWORK_IDS: Record = { + mainnet: 'stellar:pubnet', + testnet: 'stellar:testnet', +} + +/** + * Registers (or updates) a resource in the Bazaar catalog. + * + * HTTP resources are identified by (network, url, httpMethod); MCP resources + * by (network, url, toolName) — the tuple the spec requires because multiple + * tools can multiplex over one MCP server endpoint. Re-registering the same + * identity updates the existing row instead of creating a duplicate, so a + * resource server can safely call this on every startup. + */ +export async function registerBazaarResource(input: RegisterBazaarResourceInput): Promise { + const payTo = input.accepts[0]?.payTo + if (!payTo) { + throw new Error('registerBazaarResource: accepts[] must contain at least one payment requirement with payTo') + } + + const base = { + type: input.type, + network: input.network, + url: input.resource.url, + description: input.resource.description ?? null, + mimeType: input.resource.mimeType ?? null, + serviceName: input.resource.serviceName ?? null, + tags: input.resource.tags ?? [], + iconUrl: input.resource.iconUrl ?? null, + mcpToolName: input.type === 'mcp' ? (input.bazaar.info.input as { toolName: string }).toolName : null, + httpMethod: input.type === 'http' ? (input.bazaar.info.input as { method: string }).method : null, + accepts: input.accepts as object, + payTo, + bazaarInfo: input.bazaar.info as object, + bazaarSchema: input.bazaar.schema as object, + routeTemplate: input.bazaar.routeTemplate ?? null, + extensionKeys: input.extensionKeys ?? ['bazaar'], + } + + if (input.type === 'mcp') { + const mcpToolName = base.mcpToolName as string + await prisma.bazaarResource.upsert({ + where: { + bazaarMcpIdentity: { network: input.network, url: input.resource.url, mcpToolName }, + }, + create: base, + update: base, + }) + } else { + const httpMethod = base.httpMethod as string + await prisma.bazaarResource.upsert({ + where: { + bazaarHttpIdentity: { network: input.network, url: input.resource.url, httpMethod }, + }, + create: base, + update: base, + }) + } +} + +export async function removeBazaarResource(network: NetworkName, url: string, key?: string): Promise { + await prisma.bazaarResource.deleteMany({ + where: { + network, + url, + OR: [{ httpMethod: key ?? undefined }, { mcpToolName: key ?? undefined }], + }, + }) +} + +/** + * Parses and clamps query-string filters for GET /discovery/resources. + * `limit` defaults to 50 and is clamped to [1, 200] to bound catalog scans; + * `offset` defaults to 0 and cannot be negative. + */ +export function parseDiscoveryFilters(query: Record): DiscoveryFilters { + const rawLimit = Number(query.limit) + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 200) : 50 + + const rawOffset = Number(query.offset) + const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0 + + const type = query.type === 'http' || query.type === 'mcp' ? query.type : undefined + const payTo = typeof query.payTo === 'string' && query.payTo.length > 0 ? query.payTo : undefined + const network = typeof query.network === 'string' && query.network.length > 0 ? query.network : undefined + const extensions = typeof query.extensions === 'string' && query.extensions.length > 0 ? query.extensions : undefined + + return { type, payTo, network, extensions, limit, offset } +} + +/** + * Maps the spec's `network` filter value (a CAIP-2 id, e.g. "stellar:pubnet" + * or "stellar:testnet") to our internal NetworkName column value. Falls back + * to matching the raw string directly so a facilitator that passes our + * NetworkName values (or any future non-Stellar CAIP-2 id we don't recognize + * yet) does not silently match nothing. + */ +function resolveNetworkFilter(network: string | undefined): string | undefined { + if (!network) return undefined + const entry = (Object.entries(STELLAR_NETWORK_IDS) as [NetworkName, string][]) + .find(([, caip2]) => caip2 === network) + return entry ? entry[0] : network +} + +function toListing(row: { + url: string + description: string | null + mimeType: string | null + serviceName: string | null + tags: string[] + iconUrl: string | null + accepts: unknown + bazaarInfo: unknown + bazaarSchema: unknown + routeTemplate: string | null + extensionKeys: string[] +}): BazaarResourceListing { + const extensions: BazaarResourceListing['extensions'] = { + bazaar: { + info: row.bazaarInfo as BazaarResourceListing['extensions']['bazaar']['info'], + schema: row.bazaarSchema as Record, + ...(row.routeTemplate ? { routeTemplate: row.routeTemplate } : {}), + }, + } + + return { + resource: { + url: row.url, + ...(row.description ? { description: row.description } : {}), + ...(row.mimeType ? { mimeType: row.mimeType } : {}), + ...(row.serviceName ? { serviceName: row.serviceName } : {}), + ...(row.tags.length > 0 ? { tags: row.tags } : {}), + ...(row.iconUrl ? { iconUrl: row.iconUrl } : {}), + }, + accepts: row.accepts as BazaarResourceListing['accepts'], + extensions, + } +} + +/** + * GET /discovery/resources — paginated catalog query implementing the six + * spec filters: + * - type: exact match on "http" | "mcp" + * - payTo: exact match against the resource's payment recipient + * - network: matches either our NetworkName ("mainnet"/"testnet") or the + * CAIP-2 id the spec's examples use ("stellar:pubnet"/"stellar:testnet") + * - extensions: matches resources that declare the given extension key + * (the spec's example is "bazaar", which every row declares by default) + * - limit / offset: standard offset pagination + * + * Ordering is (createdAt DESC, id DESC) — a stable tiebreaker on the primary + * key — so that concurrent inserts during a paginated walk never shift + * already-returned rows to a different page (the classic offset-pagination + * hazard when ordering by a non-unique column alone). + */ +export async function queryDiscoveryResources(filters: DiscoveryFilters): Promise { + const where = { + ...(filters.type ? { type: filters.type } : {}), + ...(filters.payTo ? { payTo: filters.payTo } : {}), + ...(filters.network ? { network: resolveNetworkFilter(filters.network) } : {}), + ...(filters.extensions ? { extensionKeys: { has: filters.extensions } } : {}), + } + + const [rows, total] = await Promise.all([ + prisma.bazaarResource.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: filters.limit, + skip: filters.offset, + }), + prisma.bazaarResource.count({ where }), + ]) + + return { + resources: rows.map(toListing), + limit: filters.limit, + offset: filters.offset, + total, + } +} diff --git a/src/bazaar/types.ts b/src/bazaar/types.ts new file mode 100644 index 0000000..9f30346 --- /dev/null +++ b/src/bazaar/types.ts @@ -0,0 +1,87 @@ +import type { NetworkName } from '../config' + +/** + * Shapes for the x402 `bazaar` discovery extension + * (specs/extensions/bazaar.md in x402-foundation/x402). + * + * These mirror the spec's `resource` + `accepts` + `extensions.bazaar` shape + * used in a 402 PaymentRequired response — a discovery listing is that same + * shape, catalogued. + */ + +export interface BazaarResourceInfo { + url: string + description?: string + mimeType?: string + serviceName?: string + tags?: string[] + iconUrl?: string +} + +export interface BazaarPaymentRequirement { + scheme: string + network: string + amount: string + asset: string + payTo: string + maxTimeoutSeconds: number + extra?: Record +} + +export type BazaarHttpInput = + | { type: 'http'; method: 'GET' | 'HEAD' | 'DELETE'; queryParams?: Record; pathParams?: Record } + | { type: 'http'; method: 'POST' | 'PUT' | 'PATCH'; bodyType: string; body?: Record; pathParams?: Record } + +export interface BazaarMcpInput { + type: 'mcp' + toolName: string + inputSchema: Record + transport?: 'streamable-http' | 'sse' +} + +export interface BazaarInfo { + input: BazaarHttpInput | BazaarMcpInput + output?: Record +} + +export interface BazaarExtensionDeclaration { + info: BazaarInfo + schema: Record + routeTemplate?: string +} + +/** A single item in the GET /discovery/resources response. */ +export interface BazaarResourceListing { + resource: BazaarResourceInfo + accepts: BazaarPaymentRequirement[] + extensions: { + bazaar: BazaarExtensionDeclaration + [key: string]: unknown + } +} + +/** Input for registering a new listing in the catalog. */ +export interface RegisterBazaarResourceInput { + type: 'http' | 'mcp' + network: NetworkName + resource: BazaarResourceInfo + accepts: BazaarPaymentRequirement[] + bazaar: BazaarExtensionDeclaration + extensionKeys?: string[] +} + +export interface DiscoveryFilters { + type?: 'http' | 'mcp' + payTo?: string + network?: string + extensions?: string + limit: number + offset: number +} + +export interface DiscoveryResponse { + resources: BazaarResourceListing[] + limit: number + offset: number + total: number +} diff --git a/src/config.ts b/src/config.ts index aea5277..8d8ffd2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,12 +18,24 @@ export interface NetworkConfig { passphrase: string } soroswap: { + /** Whether Soroswap has a usable deployment on this network. */ + enabled: boolean /** Soroswap factory contract address for this network. */ factoryAddress: string + /** Soroswap token-list URL for this network. */ + tokenListUrl: string /** How often to poll Soroswap pool reserves in ms. */ pollIntervalMs: number } + aquarius: { + /** Whether Aquarius has a usable deployment on this network. */ + enabled: boolean + /** Aquarius AMM pools API base URL for this network. */ + apiUrl: string + } oracle: { + /** Whether the Reflector oracle is deployed on this network. */ + enabled: boolean /** Reflector oracle contract ID for this network. */ reflectorContractId: string } @@ -106,6 +118,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2' : 'CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3') + // Soroswap token-list is a single canonical list covering both networks by + // default, but can be overridden per network (e.g. a testnet-specific list). + const soroswapTokenListUrl = + process.env[`SOROSWAP_TOKEN_LIST_URL_${suffix}`] || + process.env.SOROSWAP_TOKEN_LIST_URL || + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + + const soroswapEnabled = + (process.env[`SOROSWAP_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' + const soroswapPollMs = parseInt( process.env[`SOROSWAP_POLL_INTERVAL_MS_${suffix}`] || process.env.SOROSWAP_POLL_INTERVAL_MS || @@ -113,6 +135,17 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { 10 ) + // ── Aquarius ────────────────────────────────────────────────────────────── + // Aquarius only runs on Stellar classic mainnet today — there is no public + // testnet deployment, so it is disabled there by default. + const aquariusApiUrl = + process.env[`AQUARIUS_API_URL_${suffix}`] || + process.env.AQUARIUS_API_URL || + 'https://amm.aquarius.network/api/v1/pools/' + + const aquariusEnabled = + (process.env[`AQUARIUS_ENABLED_${suffix}`] ?? (network === 'mainnet' ? 'true' : 'false')).toLowerCase() !== 'false' + // ── Reflector oracle ────────────────────────────────────────────────────── const reflectorContractId = process.env[`REFLECTOR_CONTRACT_ID_${suffix}`] || @@ -121,6 +154,10 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA' : '') + const oracleEnabled = + (process.env[`REFLECTOR_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + reflectorContractId !== '' + // ── Watched pairs ───────────────────────────────────────────────────────── const rawPairs = process.env[`WATCHED_PAIRS_${suffix}`] || @@ -132,10 +169,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { rpc: { url: rpcUrl }, network: { passphrase }, soroswap: { + enabled: soroswapEnabled, factoryAddress: soroswapFactory, + tokenListUrl: soroswapTokenListUrl, pollIntervalMs: soroswapPollMs, }, - oracle: { reflectorContractId }, + aquarius: { + enabled: aquariusEnabled, + apiUrl: aquariusApiUrl, + }, + oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), } } @@ -229,6 +272,7 @@ export const config = { get rpc() { return resolveNetwork(activeNetwork).rpc }, get network() { return resolveNetwork(activeNetwork).network }, get soroswap() { return resolveNetwork(activeNetwork).soroswap }, + get aquarius() { return resolveNetwork(activeNetwork).aquarius }, get oracle() { return resolveNetwork(activeNetwork).oracle }, get pairs() { return resolveNetwork(activeNetwork).pairs }, diff --git a/src/index.ts b/src/index.ts index 1d12cb8..538dbab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { registerPairsRoutes } from './routes/pairs' import { registerScreenerRoutes } from './routes/screener' import { registerHistoryRoutes } from './api/history' import { registerX402 } from './middleware/x402' +import { registerNetworkSelector } from './middleware/network' import { registerWebSocket } from './api/websocket' import { registerApiKeyAuth } from './api/auth' import { registerAdminRoutes } from './api/admin' @@ -28,6 +29,7 @@ import { registerVolumeRoutes } from './routes/volumes' import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' +import { registerDiscoveryRoutes } from './routes/discovery' import { fanOutManager } from './ws/fanout' import { startSDEXIngester } from './ingesters/sdex' @@ -61,6 +63,12 @@ async function main() { await app.register(cors, { origin: true }) await app.register(compress) + // Resolves the per-request Stellar network (?network= query param / x-network + // header) onto req.network, validating it (400 on an unrecognised value). + // Runs in onRequest, ahead of API-key auth/rate-limiting/x402 and every route + // handler, so all of them can read req.network. + await app.register(registerNetworkSelector) + // API-key authentication — validates Authorization: Bearer and attaches // per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so // that req.apiKey is populated when the limiter evaluates its per-key quota @@ -121,6 +129,7 @@ async function main() { await registerBenchmarkRoutes(app) await registerOracleRoutes(app) await registerBasketRoutes(app) + await registerDiscoveryRoutes(app) await registerGraphQL(app) await registerWebSocket(app) diff --git a/src/ingest/oracles/reflector.ts b/src/ingest/oracles/reflector.ts index 6bcee44..1323a9a 100644 --- a/src/ingest/oracles/reflector.ts +++ b/src/ingest/oracles/reflector.ts @@ -17,22 +17,12 @@ import { nativeToScVal, Account, } from '@stellar/stellar-sdk' -import { config } from '../../config' - -// Reflector oracle contract address — resolved per active network via config. -// Set REFLECTOR_CONTRACT_ID_TESTNET / REFLECTOR_CONTRACT_ID_MAINNET (or the -// legacy REFLECTOR_CONTRACT_ID fallback) in your environment. -const REFLECTOR_CONTRACT_ID = config.oracle.reflectorContractId +import { activeNetwork, getNetworkConfig, type NetworkName } from '../../config' +import { getRpcServer } from '../../network/clients' // Ephemeral fee payer — simulation only, no real funds needed const FEE_PAYER = Keypair.random() -let _rpc: SorobanRpc.Server | null = null -function getRpc(): SorobanRpc.Server { - _rpc ??= new SorobanRpc.Server(config.rpc.url, { allowHttp: true }) - return _rpc -} - export interface ReflectorPrice { asset: string price: number @@ -40,18 +30,27 @@ export interface ReflectorPrice { } /** - * Fetch the latest price for a given asset code from the Reflector oracle. - * Returns null when the contract is unreachable or the asset is unknown. + * Fetch the latest price for a given asset code from the Reflector oracle on + * the given network. Returns null when the contract is unreachable, the + * network has no Reflector deployment configured, or the asset is unknown. */ -export async function fetchReflectorPrice(assetCode: string): Promise { +export async function fetchReflectorPrice( + assetCode: string, + network: NetworkName = activeNetwork +): Promise { + const netConfig = getNetworkConfig(network) + // `oracle.enabled` already implies a non-empty reflectorContractId (config.ts), + // so this single check subsumes the contract-id guard. + if (!netConfig.oracle.enabled) return null + try { - const rpc = getRpc() - const contract = new Contract(REFLECTOR_CONTRACT_ID) + const rpc = getRpcServer(network) + const contract = new Contract(netConfig.oracle.reflectorContractId) const account = new Account(FEE_PAYER.publicKey(), '0') const tx = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: config.network.passphrase, + networkPassphrase: netConfig.network.passphrase, }) .addOperation( contract.call('lastprice', nativeToScVal(assetCode, { type: 'symbol' })) @@ -86,13 +85,16 @@ export async function fetchReflectorPrice(assetCode: string): Promise() const CACHE_TTL_MS = 60_000 -export async function getCachedReflectorPrice(asset: string): Promise { - const key = asset.toUpperCase() +export async function getCachedReflectorPrice( + asset: string, + network: NetworkName = activeNetwork +): Promise { + const key = `${network}:${asset.toUpperCase()}` const entry = _cache.get(key) if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) { return entry.price } - const fresh = await fetchReflectorPrice(key) + const fresh = await fetchReflectorPrice(asset, network) if (fresh) { _cache.set(key, { price: fresh.price, fetchedAt: Date.now() }) return fresh.price diff --git a/src/ingest/venues/aquarius.ts b/src/ingest/venues/aquarius.ts index a609d72..5e0fab3 100644 --- a/src/ingest/venues/aquarius.ts +++ b/src/ingest/venues/aquarius.ts @@ -14,8 +14,6 @@ import { upsertPricePoints } from '../../db' import { dispatchPriceUpdate } from '../../webhookDispatcher' import type { WatchedPair } from '../../types' -const AQUARIUS_AMM_API = 'https://amm.aquarius.network/api/v1/pools/' - const lastPrice = new Map() interface AquariusPool { @@ -28,7 +26,10 @@ interface AquariusListResponse { results?: AquariusPool[] } -export async function fetchAquariusPools(pair: WatchedPair): Promise { +export async function fetchAquariusPools( + pair: WatchedPair, + apiUrl: string = config.aquarius.apiUrl +): Promise { try { const assetAStr = pair.assetA.issuer ? `${pair.assetA.code}:${pair.assetA.issuer}` @@ -41,7 +42,7 @@ export async function fetchAquariusPools(pair: WatchedPair): Promise { + if (!config.aquarius.enabled) { + console.log('[aquarius] Aquarius is disabled on this network — ingester not started') + return + } + console.log(`[aquarius] Starting Aquarius AMM ingester for ${getActivePairs().length} pairs`) while (true) { for (const pair of getActivePairs()) { diff --git a/src/ingesters/amm.ts b/src/ingesters/amm.ts index f2aae24..8093c83 100644 --- a/src/ingesters/amm.ts +++ b/src/ingesters/amm.ts @@ -1,16 +1,13 @@ -import { Horizon } from '@stellar/stellar-sdk' import { amm_snapshots_total, trades_ingested_total, last_trade_timestamp } from '../metrics' -import { config } from '../config' +import { config, activeNetwork, getNetworkConfig, type NetworkName } from '../config' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints, getIndexerCursor, setIndexerCursor, prisma } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' import type { WatchedPair } from '../types' -const horizonServer = new Horizon.Server(config.horizon.url) - const lastPrice = new Map() -export async function fetchPools(pair: WatchedPair): Promise { +export async function fetchPools(pair: WatchedPair, network: NetworkName = activeNetwork): Promise { try { // Use Horizon's reserves filter to find pools for this specific pair const assetAStr = pair.assetA.issuer @@ -26,7 +23,7 @@ export async function fetchPools(pair: WatchedPair): Promise { params.set('limit', '10') const response = await fetch( - `${config.horizon.url}/liquidity_pools?${params.toString()}` + `${getNetworkConfig(network).horizon.url}/liquidity_pools?${params.toString()}` ) const data = await response.json() as any if (!data._embedded?.records) return [] @@ -97,13 +94,17 @@ export async function snapshotPool(pool: any, pair: WatchedPair): Promise } } -export async function ingestPoolTrades(pool: any, pair: WatchedPair): Promise { - const stateId = `amm:${pool.id}` +export async function ingestPoolTrades( + pool: any, + pair: WatchedPair, + network: NetworkName = activeNetwork +): Promise { + const stateId = `amm:${network}:${pool.id}` const cursor = await getIndexerCursor(stateId) ?? '0' try { const response = await fetch( - `${config.horizon.url}/liquidity_pools/${pool.id}/trades?cursor=${cursor}&limit=${config.indexer.ammPageSize}&order=asc` + `${getNetworkConfig(network).horizon.url}/liquidity_pools/${pool.id}/trades?cursor=${cursor}&limit=${config.indexer.ammPageSize}&order=asc` ) const data = await response.json() as any const records = data._embedded?.records ?? [] @@ -161,17 +162,17 @@ async function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) } -export async function startAMMIngester(): Promise { - console.log(`[amm] Starting AMM ingester for ${getActivePairs().length} pairs`) +export async function startAMMIngester(network: NetworkName = activeNetwork): Promise { + console.log(`[amm] Starting AMM ingester for ${getActivePairs().length} pairs on ${network}`) while (true) { for (const pair of getActivePairs()) { - const pools = await fetchPools(pair) + const pools = await fetchPools(pair, network) console.log(`[amm] ${pair.pairKey}: found ${pools.length} AMM pools`) await Promise.all(pools.map(async pool => { await snapshotPool(pool, pair) - await ingestPoolTrades(pool, pair) + await ingestPoolTrades(pool, pair, network) })) } await sleep(config.indexer.pollIntervalMs) diff --git a/src/ingesters/sdex.ts b/src/ingesters/sdex.ts index 0218572..e5022cc 100644 --- a/src/ingesters/sdex.ts +++ b/src/ingesters/sdex.ts @@ -1,13 +1,12 @@ -import { Horizon, Asset } from '@stellar/stellar-sdk' +import { Asset } from '@stellar/stellar-sdk' import { trades_ingested_total, last_trade_timestamp } from '../metrics' -import { config } from '../config' +import { config, activeNetwork, type NetworkName } from '../config' +import { getHorizonServer } from '../network/clients' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints, getIndexerCursor, setIndexerCursor } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' import type { WatchedPair } from '../types' -const horizonServer = new Horizon.Server(config.horizon.url) - // Last seen price per pairKey — used for threshold crossing detection const lastPrice = new Map() @@ -16,15 +15,15 @@ function toAsset(asset: { code: string; issuer: string | null }): Asset { return new Asset(asset.code, asset.issuer) } -export async function ingestPair(pair: WatchedPair): Promise { - const stateId = `sdex:${pair.pairKey}` +export async function ingestPair(pair: WatchedPair, network: NetworkName = activeNetwork): Promise { + const stateId = `sdex:${network}:${pair.pairKey}` const cursor = await getIndexerCursor(stateId) ?? '0' try { const assetA = toAsset(pair.assetA) const assetB = toAsset(pair.assetB) - const trades = await horizonServer + const trades = await getHorizonServer(network) .trades() .forAssetPair(assetA, assetB) .cursor(cursor) @@ -87,11 +86,11 @@ async function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) } -export async function startSDEXIngester(): Promise { - console.log(`[sdex] Starting SDEX ingester for ${getActivePairs().length} pairs`) +export async function startSDEXIngester(network: NetworkName = activeNetwork): Promise { + console.log(`[sdex] Starting SDEX ingester for ${getActivePairs().length} pairs on ${network}`) while (true) { - await Promise.all(getActivePairs().map(pair => ingestPair(pair))) + await Promise.all(getActivePairs().map(pair => ingestPair(pair, network))) await sleep(config.indexer.pollIntervalMs) } } diff --git a/src/ingesters/soroswap.ts b/src/ingesters/soroswap.ts index b1accc2..183e69f 100644 --- a/src/ingesters/soroswap.ts +++ b/src/ingesters/soroswap.ts @@ -19,7 +19,8 @@ import { scValToNative, Account, } from '@stellar/stellar-sdk' -import { config } from '../config' +import { activeNetwork, getNetworkConfig, type NetworkName } from '../config' +import { getRpcServer } from '../network/clients' import { getActivePairs } from '../pairsRegistry' import { upsertPricePoints } from '../db' import { dispatchPriceUpdate } from '../webhookDispatcher' @@ -27,9 +28,6 @@ import type { WatchedPair } from '../types' // ── Constants ───────────────────────────────────────────────────────────────── -const SOROSWAP_TOKEN_LIST_URL = - 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' - // Ephemeral fee-payer account (no real funds needed for simulation) const FEE_PAYER_KEYPAIR = Keypair.random() @@ -55,25 +53,17 @@ export interface PoolEntry { tokenB: SoroswapToken } -// ── RPC client (lazy-initialised so tests can skip it) ──────────────────────── - -let _rpc: SorobanRpc.Server | null = null -function getRpc(): SorobanRpc.Server { - if (!_rpc) { - _rpc = new SorobanRpc.Server(config.rpc.url, { allowHttp: true }) - } - return _rpc -} - // ── Token-list helpers ──────────────────────────────────────────────────────── /** * Fetch Soroswap token list. Returns an empty array on failure so the ingester * degrades gracefully without affecting other ingesters. */ -export async function fetchSoroswapTokenList(): Promise { +export async function fetchSoroswapTokenList( + tokenListUrl: string = getNetworkConfig(activeNetwork).soroswap.tokenListUrl +): Promise { try { - const res = await fetch(SOROSWAP_TOKEN_LIST_URL) + const res = await fetch(tokenListUrl) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as SoroswapTokenList return Array.isArray(data.tokens) ? data.tokens : [] @@ -92,14 +82,15 @@ export async function fetchSoroswapTokenList(): Promise { export async function fetchPoolsFromFactory( factoryAddress: string, tokenA: string, - tokenB: string + tokenB: string, + network: NetworkName = activeNetwork ): Promise { try { - const rpc = getRpc() + const rpc = getRpcServer(network) const factory = new Contract(factoryAddress) const account = new Account(FEE_PAYER_KEYPAIR.publicKey(), '0') const networkPassphrase = - config.network.passphrase ?? Networks.PUBLIC + getNetworkConfig(network).network.passphrase ?? Networks.PUBLIC const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -146,14 +137,15 @@ export async function fetchPoolsFromFactory( * Returns null on any RPC error. */ export async function fetchPoolReserves( - poolAddress: string + poolAddress: string, + network: NetworkName = activeNetwork ): Promise<[bigint, bigint] | null> { try { - const rpc = getRpc() + const rpc = getRpcServer(network) const pool = new Contract(poolAddress) const account = new Account(FEE_PAYER_KEYPAIR.publicKey(), '0') const networkPassphrase = - config.network.passphrase ?? Networks.PUBLIC + getNetworkConfig(network).network.passphrase ?? Networks.PUBLIC const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -212,10 +204,11 @@ export function calcSpotPrice(reserveA: bigint, reserveB: bigint): number { export async function ingestPool( poolEntry: PoolEntry, pair: WatchedPair, - _fetchReserves = fetchPoolReserves + _fetchReserves: (poolAddress: string, network?: NetworkName) => Promise<[bigint, bigint] | null> = fetchPoolReserves, + network: NetworkName = activeNetwork ): Promise { try { - const reserves = await _fetchReserves(poolEntry.poolAddress) + const reserves = await _fetchReserves(poolEntry.poolAddress, network) if (!reserves) return const [reserveA, reserveB] = reserves @@ -277,7 +270,13 @@ export async function ingestPair( pair: WatchedPair, tokens: SoroswapToken[], factoryAddress: string, - _fetchPools = fetchPoolsFromFactory + _fetchPools: ( + factoryAddress: string, + tokenA: string, + tokenB: string, + network?: NetworkName + ) => Promise = fetchPoolsFromFactory, + network: NetworkName = activeNetwork ): Promise { const tokenA = tokens.find( (t) => t.symbol.toUpperCase() === pair.assetA.code.toUpperCase() @@ -296,7 +295,8 @@ export async function ingestPair( const poolAddresses = await _fetchPools( factoryAddress, tokenA.address, - tokenB.address + tokenB.address, + network ) if (!poolAddresses.length) { @@ -310,7 +310,9 @@ export async function ingestPair( poolAddresses.map((addr) => ingestPool( { poolAddress: addr, tokenA, tokenB }, - pair + pair, + fetchPoolReserves, + network ) ) ) @@ -326,23 +328,31 @@ async function sleep(ms: number): Promise { * Start the Soroswap AMM ingester. Runs as an infinite polling loop. * Fault-isolated: a crash is caught by the caller (restartIngester in index.ts). */ -export async function startSoroswapIngester(): Promise { - const factoryAddress = config.soroswap.factoryAddress - const pollInterval = config.soroswap.pollIntervalMs +export async function startSoroswapIngester(network: NetworkName = activeNetwork): Promise { + const netConfig = getNetworkConfig(network) + + if (!netConfig.soroswap.enabled) { + console.log(`[soroswap] Soroswap is disabled on ${network} — ingester not started`) + return + } + + const factoryAddress = netConfig.soroswap.factoryAddress + const tokenListUrl = netConfig.soroswap.tokenListUrl + const pollInterval = netConfig.soroswap.pollIntervalMs console.log( - `[soroswap] Starting Soroswap ingester | factory=${factoryAddress} | interval=${pollInterval}ms` + `[soroswap] Starting Soroswap ingester on ${network} | factory=${factoryAddress} | interval=${pollInterval}ms` ) while (true) { const pairs = getActivePairs() - const tokens = await fetchSoroswapTokenList() + const tokens = await fetchSoroswapTokenList(tokenListUrl) if (tokens.length === 0) { console.warn('[soroswap] Token list empty — skipping poll cycle') } else { await Promise.all( - pairs.map((pair) => ingestPair(pair, tokens, factoryAddress)) + pairs.map((pair) => ingestPair(pair, tokens, factoryAddress, fetchPoolsFromFactory, network)) ) } diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts new file mode 100644 index 0000000..a963122 --- /dev/null +++ b/src/mcp/codes.ts @@ -0,0 +1,18 @@ +export const MCP_ERROR_CODES = { + ERR_PAYMENT_REJECTED: 'ERR_PAYMENT_REJECTED', + ERR_INSUFFICIENT_BALANCE: 'ERR_INSUFFICIENT_BALANCE', + ERR_EXPIRED_AUTHORISATION: 'ERR_EXPIRED_AUTHORISATION', + ERR_MISSING_TRUSTLINE: 'ERR_MISSING_TRUSTLINE', + ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', + ERR_NO_RESULTS: 'ERR_NO_RESULTS', + ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' +} as const; + +export type McpErrorCode = keyof typeof MCP_ERROR_CODES; + +export interface McpErrorResponse { + isError: true; + code: McpErrorCode; + reason: string; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..1c401a7 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,179 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; + +export interface McpServerConfig { + fetchWithPayment: typeof fetch; +} + +export class BazaarMcpServer { + private server: Server; + private fetchWithPayment: typeof fetch; + + constructor(config: McpServerConfig) { + this.fetchWithPayment = config.fetchWithPayment; + this.server = new Server( + { + name: 'bazaar-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'bazaar_search', + description: 'Search for resources in a Stellar Bazaar node', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The base URL of the Bazaar API', + }, + query: { + type: 'string', + description: 'Optional search query', + }, + }, + required: ['url'], + }, + }, + { + name: 'paid_call', + description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The full URL of the resource to fetch', + }, + method: { + type: 'string', + description: 'HTTP method (default: GET)', + }, + }, + required: ['url'], + }, + }, + ], + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'bazaar_search') { + const url = String(request.params.arguments?.url); + const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; + + try { + const endpoint = query + ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` + : `${url.replace(/\/$/, '')}/discovery/resources`; + + const res = await fetch(endpoint); + + if (!res.ok) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); + } + + const data = await res.json(); + + const hasResources = (d: unknown): d is { resources: unknown[] } => + typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); + + if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { + return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); + } + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); + } + } + + if (request.params.name === 'paid_call') { + const url = String(request.params.arguments?.url); + const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; + + try { + const res = await this.fetchWithPayment(url, { method }); + + if (!res.ok) { + if (res.status === 402) { + return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + } + return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + } + + const text = await res.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + return { + content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], + }; + } catch (e: any) { + return this.handlePaidCallError(e); + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); + }); + } + + private handlePaidCallError(e: any) { + const msg = String(e.message || e).toLowerCase(); + + if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { + return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + } + + if (msg.includes('trustline') || msg.includes('op_no_trust')) { + return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); + } + + if (msg.includes('insufficient') || msg.includes('underfunded')) { + return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); + } + + if (msg.includes('fetch') || msg.includes('network')) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); + } + + return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + } + + private createErrorResponse(code: McpErrorCode, reason: string) { + // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + const payload = { + isError: true, + code, + reason, + }; + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: true, + }; + } + + async run() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} diff --git a/src/middleware/network.ts b/src/middleware/network.ts new file mode 100644 index 0000000..8519559 --- /dev/null +++ b/src/middleware/network.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import fp from 'fastify-plugin' +import { activeNetwork, type NetworkName } from '../config' + +const VALID_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] + +declare module 'fastify' { + interface FastifyRequest { + /** + * The Stellar network this request targets, resolved from the `network` + * query param / `x-network` header (see {@link resolveNetworkName}). + * Defaults to `activeNetwork` when the request specifies nothing. + */ + network: NetworkName + } +} + +/** + * Resolves a raw `network` value (query param or header) into a validated + * {@link NetworkName}. An absent/empty value resolves to `activeNetwork` + * (this deployment's configured default) rather than being an error — only + * an explicit, unrecognised value is rejected. + */ +export function resolveNetworkName( + raw: string | undefined | null +): { ok: true; network: NetworkName } | { ok: false; error: string } { + if (raw == null || raw === '') return { ok: true, network: activeNetwork } + const lower = raw.trim().toLowerCase() + if ((VALID_NETWORKS as string[]).includes(lower)) { + return { ok: true, network: lower as NetworkName } + } + return { + ok: false, + error: `Invalid network "${raw}" — expected one of: ${VALID_NETWORKS.join(', ')}`, + } +} + +function rawNetworkFromRequest(req: FastifyRequest): string | undefined { + const fromQuery = (req.query as Record | undefined)?.network + if (typeof fromQuery === 'string') return fromQuery + + const fromHeader = req.headers['x-network'] + if (typeof fromHeader === 'string') return fromHeader + + return undefined +} + +/** + * Fastify plugin that resolves the per-request Stellar network from a + * `?network=` query param or `x-network` header, validates it, and attaches + * it to `req.network`. An unrecognised value gets a 400 before any route + * handler or downstream middleware (x402, WebSocket auth) runs. + * + * Must be registered early (`onRequest`) so `req.network` is populated + * before `middleware/x402.ts`'s `preHandler` hook and any route handler. + */ +async function networkSelectorPlugin(app: FastifyInstance) { + app.decorateRequest('network', activeNetwork) + + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + const resolved = resolveNetworkName(rawNetworkFromRequest(req)) + if (!resolved.ok) { + reply.status(400).send({ error: resolved.error }) + return + } + req.network = resolved.network + }) +} + +export const registerNetworkSelector = fp(networkSelectorPlugin, { name: 'network-selector' }) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index e16ce89..bc1da10 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -1,11 +1,9 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { x402_payments_received_total } from '../metrics' import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/metering' +import { X402_NETWORK_LABEL, paymentAddressFor, isX402Configured, getX402ResourceServer } from '../x402/network' import fp from 'fastify-plugin' -// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution -import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' -// @ts-ignore -import { ExactStellarScheme } from '@x402/stellar/exact/server' +import './network' // declares req.network on the FastifyRequest type // Routes gated by x402 and their prices const GATED_ROUTES: Record = { @@ -21,20 +19,13 @@ const GATED_ROUTES: Record = { */ async function x402Plugin(app: FastifyInstance) { // Read at plugin init time (not module load) so tests can inject env vars before app.register() - const PAYMENT_ADDRESS = process.env.ORACLE_PAYMENT_ADDRESS const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://facilitator.stellar.org' - const NETWORK = (process.env.STELLAR_NETWORK === 'mainnet' ? 'stellar:pubnet' : 'stellar:testnet') as string - if (!PAYMENT_ADDRESS) { + if (!isX402Configured()) { app.log.warn('[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled') return } - const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) - const resourceServer: any = new x402ResourceServer(facilitatorClient) - .register(NETWORK as `${string}:${string}`, new ExactStellarScheme()) - - await resourceServer.initialize() app.log.info('[oracle] x402 payment gating enabled') app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => { @@ -46,14 +37,23 @@ async function x402Plugin(app: FastifyInstance) { }) if (!matchedRoute) return + // Falls back to testnet when the network selector plugin isn't + // registered (e.g. isolated unit tests that build the app directly). + const network = req.network ?? 'testnet' + const paymentAddress = paymentAddressFor(network) + if (!paymentAddress) { + reply.status(402).send({ error: `x402 payments are not configured for network "${network}"` }) + return + } + const { price, description } = GATED_ROUTES[matchedRoute] const paymentHeader = req.headers['x-payment'] as string | undefined const requirements = { scheme: 'exact' as const, price, - network: NETWORK, - payTo: PAYMENT_ADDRESS, + network: X402_NETWORK_LABEL[network], + payTo: paymentAddress, } // No payment header — return 402 with requirements @@ -76,6 +76,7 @@ async function x402Plugin(app: FastifyInstance) { payload = JSON.parse(paymentHeader) } + const resourceServer = await getX402ResourceServer(network, FACILITATOR_URL) const result = await resourceServer.verify(payload, requirements) if (!result.isValid) { reply.status(402).send({ error: 'Payment invalid', reason: result.invalidReason }) diff --git a/src/network/clients.ts b/src/network/clients.ts new file mode 100644 index 0000000..0841d03 --- /dev/null +++ b/src/network/clients.ts @@ -0,0 +1,47 @@ +/** + * Per-network Horizon / Soroban RPC client factories. + * + * Every ingester and aggregator that talks to a Stellar network needs a + * Horizon.Server or SorobanRpc.Server bound to that network's endpoints. + * Previously these were created once as module-level singletons bound to + * whatever network was active at import time, which made it impossible to + * run ingesters against more than one network in the same process. + * + * getHorizonServer(network) / getRpcServer(network) return a client cached + * per network so repeated calls for the same network reuse one instance, + * while different networks always resolve to distinct clients. + */ + +import { Horizon, rpc as SorobanRpc } from '@stellar/stellar-sdk' +import { getNetworkConfig, type NetworkName } from '../config' + +const horizonClients = new Map() +const rpcClients = new Map() + +export function getHorizonServer(network: NetworkName): Horizon.Server { + let client = horizonClients.get(network) + if (!client) { + client = new Horizon.Server(getNetworkConfig(network).horizon.url) + horizonClients.set(network, client) + } + return client +} + +export function getRpcServer(network: NetworkName): SorobanRpc.Server { + let client = rpcClients.get(network) + if (!client) { + client = new SorobanRpc.Server(getNetworkConfig(network).rpc.url, { allowHttp: true }) + rpcClients.set(network, client) + } + return client +} + +/** + * Test-only: drops the memoised clients so a test can rebind the mocked + * Horizon/RPC constructors between cases. Production code never calls this — + * clearing the cache mid-run would silently open new connections. + */ +export function resetNetworkClients(): void { + horizonClients.clear() + rpcClients.clear() +} diff --git a/src/routes/discovery.ts b/src/routes/discovery.ts new file mode 100644 index 0000000..6b91b7e --- /dev/null +++ b/src/routes/discovery.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify' +import { parseDiscoveryFilters, queryDiscoveryResources } from '../bazaar/catalog' + +/** + * Registers GET /discovery/resources — the Bazaar catalog-browsing endpoint + * from the x402 `bazaar` extension (specs/extensions/bazaar.md in + * x402-foundation/x402). Public and un-gated: discovery has to work before a + * client has any payment method configured, the same reasoning that keeps + * GET /supported un-gated (see routes/facilitator.ts). + */ +export async function registerDiscoveryRoutes(app: FastifyInstance) { + app.get('/discovery/resources', { config: { public: true } }, async (req) => { + const filters = parseDiscoveryFilters(req.query as Record) + return queryDiscoveryResources(filters) + }) +} diff --git a/src/x402/network.ts b/src/x402/network.ts new file mode 100644 index 0000000..b2e9aed --- /dev/null +++ b/src/x402/network.ts @@ -0,0 +1,53 @@ +import type { NetworkName } from '../config' +// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution +import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' +// @ts-ignore +import { ExactStellarScheme } from '@x402/stellar/exact/server' + +/** x402 chain identifier per Stellar network. */ +export const X402_NETWORK_LABEL: Record = { + testnet: 'stellar:testnet', + mainnet: 'stellar:pubnet', +} + +/** + * The x402 payment address for a given network. + * + * Resolution order (first non-empty wins), mirroring `config.ts`'s per-network + * env var convention: + * 1. `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` + * 2. `ORACLE_PAYMENT_ADDRESS` (back-compat with single-network setups) + */ +export function paymentAddressFor(network: NetworkName): string | undefined { + const suffix = network.toUpperCase() + return process.env[`ORACLE_PAYMENT_ADDRESS_${suffix}`] || process.env.ORACLE_PAYMENT_ADDRESS +} + +/** True if x402 gating should be active for at least one network. */ +export function isX402Configured(): boolean { + return Boolean(paymentAddressFor('testnet') || paymentAddressFor('mainnet')) +} + +// One resource server per network, built and initialised lazily on first use +// so a network that's never requested never pays the initialize() cost. +const resourceServers = new Map>() + +export function getX402ResourceServer(network: NetworkName, facilitatorUrl: string): Promise { + let pending = resourceServers.get(network) + if (!pending) { + pending = (async () => { + const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }) + const server: any = new x402ResourceServer(facilitatorClient) + .register(X402_NETWORK_LABEL[network], new ExactStellarScheme()) + await server.initialize() + return server + })() + resourceServers.set(network, pending) + } + return pending +} + +/** Test-only: clears the memoised resource servers between test cases. */ +export function _resetX402ResourceServers(): void { + resourceServers.clear() +} diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts index a842e61..f63de67 100644 --- a/tests/aggregator.property.test.ts +++ b/tests/aggregator.property.test.ts @@ -10,9 +10,11 @@ vi.mock('../src/db', () => ({ }, })) -vi.mock('@stellar/stellar-sdk', () => { +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() const callFn = vi.fn() return { + ...actual, Horizon: { Server: vi.fn(function () { return { @@ -27,6 +29,13 @@ vi.mock('@stellar/stellar-sdk', () => { }), { native: vi.fn(() => 'native') } ), + // config.ts's buildNetworkConfig() falls back to these when no + // NETWORK_PASSPHRASE_* env var is set — needed now that getBestRoute + // resolves a per-network Horizon client via getNetworkConfig(). + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, __mockCall: callFn, } }) @@ -42,7 +51,11 @@ describe('Price aggregator property tests', () => { vi.clearAllMocks() }) - it('produces valid route results for random venue prices', async () => { + // 10,000 fast-check runs of getBestRoute now actually execute (previously + // this test failed before running a single iteration — the mocked + // @stellar/stellar-sdk had no Networks export, which getNetworkConfig() + // needs); that volume of real work needs more than the 5s default. + it('produces valid route results for random venue prices', { timeout: 30000 }, async () => { await fc.assert( fc.asyncProperty( fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }), diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..8b979ad --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from 'vitest'; +import { BazaarMcpServer } from '../src/mcp/server'; +import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +describe('BazaarMcpServer', () => { + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, + // or by overriding setRequestHandler to capture our handler. + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + + // Re-setup handlers to capture the handler + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + }); + + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + }); + + it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => 'Payment Required' + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + }); + + it('should return successfully on a successful call', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: "hello" }) + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBeFalsy(); + const parsedText = JSON.parse(response.content[0].text); + expect(parsedText.success).toBe(true); + }); +}); diff --git a/tests/staleness.test.ts b/tests/staleness.test.ts index af2d6ba..2b54f0b 100644 --- a/tests/staleness.test.ts +++ b/tests/staleness.test.ts @@ -20,17 +20,25 @@ vi.mock('../src/aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +const { stalenessTestPairs } = vi.hoisted(() => ({ + stalenessTestPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + vi.mock('../src/config', () => ({ config: { - pairs: [ - { - pairKey: 'USDC/XLM', - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, - }, - ], + pairs: stalenessTestPairs, cache: { priceTtl: 10 }, }, + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + pairs: network === 'testnet' ? stalenessTestPairs : [], + }), })) import { registerRESTRoutes } from '../src/api/rest'