diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0dd23f41..79c7f5a39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,8 @@ jobs: working-directory: ${{ github.workspace }}/barge run: | bash -x start_ocean.sh --no-node --with-typesense 2>&1 > start_ocean.log & + env: + CONTRACTS_VERSION: 3.2.0-rc.0 - run: npm ci - run: npm run build - run: docker image ls @@ -226,6 +228,8 @@ jobs: working-directory: ${{ github.workspace }}/barge run: | bash -x start_ocean.sh --no-node --with-typesense 2>&1 > start_ocean.log & + env: + CONTRACTS_VERSION: 3.2.0-rc.0 - run: docker image ls - name: Delete default runner images run: | @@ -234,11 +238,13 @@ jobs: uses: actions/checkout@v4 with: repository: 'oceanprotocol/ocean-cli' + ref: 'feature/subsidy_provider' path: 'ocean-cli' - name: Checkout Ocean-js uses: actions/checkout@v4 with: repository: 'oceanprotocol/ocean.js' + ref: 'v9.3.0-next.3' path: 'ocean.js' - name: Build ocean-js working-directory: ${{ github.workspace }}/ocean.js diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9e56541c3..e9aa7cd6c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,6 +11,8 @@ on: - 'main' - 'next-4' - 'deps/node_v24' + - 'feature/user_input_subsidy' + - 'feature/subsidy_provider' env: DOCKERHUB_IMAGE: ${{ 'oceanprotocol/ocean-node' }} diff --git a/CLAUDE.md b/CLAUDE.md index 0fa68b217..2e5a2fe59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,5 +340,7 @@ can talk to `/var/run/docker.sock`. Deployment options (Docker, local Docker bui `Arhitecture.md` (note the spelling), `API.md` (full HTTP API reference — very large, plus a Postman collection), `env.md` (authoritative env-var reference), `database.md`, `Storage.md` / `persistentStorage.md`, `KeyManager.md`, `PolicyServer.md`, `services.md` -(Service-on-Demand), `compute.md` (C2D configuration: resources, GPUs, constraints, pricing), `networking.md`, `Logs.md`, +(Service-on-Demand), `compute.md` (C2D configuration: resources, GPUs, constraints, pricing), +`subsidyProviders.md` (third-party sponsorship of paid compute/services: prepaid vs refund, +zero-deposit, `SUBSIDY_PROVIDERS`), `networking.md`, `Logs.md`, `Publishing.md`, `testing.md`, `dockerDeployment.md`. diff --git a/docs/API.md b/docs/API.md index 6be6f7ad8..57217b2aa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -872,6 +872,13 @@ returns status of node "arwave": true "url": true }, + "escrowAddress": { + "8996": "0x123" + }, + "subsidyProviders": { + "8996": ["0x123", "0x456"] + }, + "subsidyProviderFilter": false, "uptime": 123, "platform": { "cpus": "123", @@ -889,6 +896,33 @@ returns status of node } ``` +`escrowAddress` and `subsidyProviders` are per-chain maps (keyed by chainId). `subsidyProviders` +reflects the node's `SUBSIDY_PROVIDERS` configuration — the Subsidy Provider contract addresses the +node passes to the escrow at **lock and claim** time (lock-time "prepaid" sponsorship and claim-time +"refund" sponsorship); it is `{}` when none are configured. Both are present in +the normal and detailed status. `subsidyProviderFilter` reflects the node's +`SUBSIDY_PROVIDER_FILTER` setting: when `true`, a user-supplied `subsidyProviders` list on a +compute/service request is restricted to addresses already in this map for the request's chain +(see the per-request `subsidyProviders` field below). + +### Per-request `subsidyProviders` + +`startCompute` (paid), `serviceStart`, and `serviceExtend` accept an optional top-level +`subsidyProviders` field: a plain array of Subsidy Provider contract addresses for the request's +payment chain. The node hands the **same** list to both the escrow lock (lock-time "prepaid" +sponsorship) and the later claim (claim-time "refund" sponsorship). It overrides the node's +`SUBSIDY_PROVIDERS` for that request only: + +- **omitted / `undefined`** → the node's configured `SUBSIDY_PROVIDERS` for the chain are used. +- **`[]`** (empty array) → the lock/claim are made with **no** subsidy providers (plain payer-funded). +- **non-empty array** → the lock/claim use **only** these addresses, ignoring node config. + +Every address must be a valid EVM address (otherwise HTTP 400). Duplicates are collapsed, and the +list may name at most **10 unique** providers (the escrow's `maxSponsorsPerLock()`); more is rejected +with HTTP 400. When the node has `SUBSIDY_PROVIDER_FILTER` enabled, every supplied address must also +be in the node's `SUBSIDY_PROVIDERS` for that chain, or the request is rejected with HTTP 400 (an +empty array is always allowed). Free compute has no escrow lock/claim, so the field is ignored there. + --- ## Query DDO @@ -1423,7 +1457,7 @@ Returns indexed Escrow contract events. The indexer matches Escrow logs by topic | --------- | ------ | --------- | --------------------------------------------------------- | | command | string | POST only | command name (`getEscrowEvents`) | | chainId | number | | chain id | -| eventType | string | | one of `Auth, Lock, Claimed, Canceled, Deposit, Withdraw` | +| eventType | string | | one of `Auth, Lock, ReLock, Claimed, Canceled, Deposit, Withdraw, Subsidized, LockSponsored, SponsorRefunded` | | payer | string | | payer address (case-insensitive) | | payee | string | | payee address (case-insensitive) | | token | string | | token address (case-insensitive) | @@ -1446,7 +1480,7 @@ Returns indexed Escrow contract events. The indexer matches Escrow logs by topic #### Response -Every row has `id, eventType, chainId, contract, block, txHash` plus event-specific fields (`payer, payee, token, jobId, amount, expiry, proof, maxLockedAmount, maxLockSeconds, maxLockCounts`). +Every row has `id, eventType, chainId, contract, block, txHash` plus event-specific fields (`payer, payee, token, jobId, amount, expiry, proof, maxLockedAmount, maxLockSeconds, maxLockCounts`). An `Auth` row additionally carries `expiryTimestamp` (Escrow v2: `0` = indefinite, otherwise the unix ts after which the payee can no longer create/extend locks). A `Subsidized` row (emitted once per contributing Subsidy Provider at claim time) additionally carries `provider, subsidyAmount, bonusAmount`. Escrow v2 lock-time sponsorship adds two events, each emitted once per contributing provider: a `LockSponsored` row (a provider pre-funds a lock at `createLock`) carries `provider, amount`; a `SponsorRefunded` row (unused sponsored tokens returned on partial claim / expiry / reLock-shrink) carries `provider, amount, reclaimable` (`reclaimable: true` ⇒ the push to the provider failed and the amount is parked for the provider to `sweepReclaimable`). ```json [ diff --git a/docs/Ocean Node.postman_collection.json b/docs/Ocean Node.postman_collection.json index c55af475e..049702fa8 100644 --- a/docs/Ocean Node.postman_collection.json +++ b/docs/Ocean Node.postman_collection.json @@ -616,7 +616,7 @@ "compute" ] }, - "description": "Start a paid compute job. Optional fields: policyServer, metadata, additionalViewers, queueMaxWaitTime, encryptedDockerRegistryAuth, output, outputBucketId." + "description": "Start a paid compute job. Optional fields: policyServer, metadata, additionalViewers, queueMaxWaitTime, encryptedDockerRegistryAuth, output, outputBucketId, subsidyProviders (array of Subsidy Provider contract addresses for the payment chain — the node hands the same list to the escrow lock and claim; omit to use the node's SUBSIDY_PROVIDERS, [] for none, max 10 unique)." } }, { @@ -920,7 +920,7 @@ "serviceStart" ] }, - "description": "Launch a long-running service container, paid via escrow. Optional: checksum, dockerfile, additionalDockerFiles, dockerCmd, dockerEntrypoint, metadata (optional user-defined labels — a flat key/value map of string/number/boolean values, node-opaque, ≤1 KB). Services must listen on a high port (>1024)." + "description": "Launch a long-running service container, paid via escrow. Optional: checksum, dockerfile, additionalDockerFiles, dockerCmd, dockerEntrypoint, metadata (optional user-defined labels — a flat key/value map of string/number/boolean values, node-opaque, ≤1 KB), subsidyProviders (array of Subsidy Provider contract addresses for the payment chain — same list used at escrow lock and claim; omit for the node's SUBSIDY_PROVIDERS, [] for none, max 10 unique; a fully-sponsored lock needs no payer deposit). Services must listen on a high port (>1024)." } }, { @@ -1069,7 +1069,7 @@ "serviceExtend" ] }, - "description": "Pay to push the service expiry further out (additionalDuration in seconds, must be positive)." + "description": "Pay to push the service expiry further out (additionalDuration in seconds, must be positive). Optional: subsidyProviders (array of Subsidy Provider contract addresses for the payment chain — same list used at escrow lock and claim; omit for the node's SUBSIDY_PROVIDERS, [] for none, max 10 unique)." } }, { @@ -1614,7 +1614,7 @@ } ] }, - "description": "Query escrow contract events with optional filters (eventType e.g. Lock/Claimed/Canceled/Deposit/Withdraw/Auth)." + "description": "Query escrow contract events with optional filters (eventType one of Auth/Lock/ReLock/Claimed/Canceled/Deposit/Withdraw/Subsidized/LockSponsored/SponsorRefunded). Subsidized is the claim-time (refund) sponsorship event; LockSponsored/SponsorRefunded are the Escrow v2 lock-time (prepaid) sponsorship events." } } ] @@ -2536,7 +2536,7 @@ "directCommand" ] }, - "description": "Query escrow events over the command interface." + "description": "Query escrow events over the command interface. Optional filters include eventType (Auth/Lock/ReLock/Claimed/Canceled/Deposit/Withdraw/Subsidized/LockSponsored/SponsorRefunded), payer, payee, token." } } ] diff --git a/docs/compute.md b/docs/compute.md index bd1eeab9a..10f777cfa 100644 --- a/docs/compute.md +++ b/docs/compute.md @@ -9,6 +9,10 @@ constraints, availability gating, and the fee structure. > On-demand **services** run on the same compute environments and draw from the same > resource pool described here — see [services.md](services.md). +> Paid compute jobs can be **subsidized** — a third party covers part or all of the cost +> (including zero-deposit jobs), optionally paying the node a bonus. See +> [subsidyProviders.md](subsidyProviders.md). + ## Contents 1. [Overview](#overview) diff --git a/docs/env.md b/docs/env.md index bc44a49bf..d9a3635e7 100644 --- a/docs/env.md +++ b/docs/env.md @@ -56,6 +56,8 @@ Environmental variables are also tracked in `ENVIRONMENT_VARIABLES` within `src/ ## Payments - `ESCROW_CLAIM_TIMEOUT`: Amount of time reserved to claim a escrow payment, in seconds. Defaults to `3600`. Example: `3600` +- `SUBSIDY_PROVIDERS`: Per-chain map (keyed by chainId) of Subsidy Provider contract addresses the node passes to the escrow at **lock and claim** time, so a third party can sponsor part (or all) of a payer's cost and/or pay the node a bonus. A provider can sponsor up front at lock time ("prepaid" — a fully-sponsored lock needs no payer deposit) and/or reimburse at claim time ("refund"), depending on the provider's configured mode. At most 10 unique providers apply to a single lock. Each chain's value is a list, so several providers can be named per chain. The addresses are normalized to their EIP-55 checksummed form and de-duplicated per chain; a malformed value (bad JSON, not a per-chain object, an invalid address, or more than 10 unique providers on a chain) is ignored (the whole map is treated as unset) rather than blocking startup. Defaults to unset (no subsidies; plain claims). Example — use the OPF Subsidy Provider on Base (chainId `8453`): `"{ \"8453\": [\"0x4344D4Bc29531DB736378e9A3dA85BF1eff0CB22\"] }"`. Multiple chains/providers: `"{ \"8453\": [\"0x4344D4Bc29531DB736378e9A3dA85BF1eff0CB22\"], \"8996\": [\"0x123\",\"0x456\"] }"` +- `SUBSIDY_PROVIDER_FILTER`: `true`/`false` (default `false`). When ON, a subsidy-provider list supplied by a user on a `startCompute` / `startService` / `serviceExtend` request may only contain addresses already present in `SUBSIDY_PROVIDERS` for that request's chain — any address outside the whitelist rejects the request (HTTP 400). When OFF, users may name any valid address — including a funded Subsidy Provider the caller has no relationship with, so an OFF filter runs an **open, sybil-drainable** sponsorship program; the node logs a startup WARN when `SUBSIDY_PROVIDERS` is set while this is OFF. Turn it ON to restrict callers to the node's own providers. A user request with no list always falls back to the node's `SUBSIDY_PROVIDERS`; a request with an empty list (`[]`) always means "no subsidy providers" and is accepted regardless of this setting. See the per-request `subsidyProviders` field in `API.md`. ## Logs diff --git a/docs/services.md b/docs/services.md index 00e68036e..fe307bc97 100644 --- a/docs/services.md +++ b/docs/services.md @@ -11,6 +11,10 @@ an algorithm to completion and exits — a service stays up for a requested **du exposes one or more network **endpoints** (`http://:`) that the consumer can connect to while it runs. +> Service payments (start and extend) can be **subsidized** by a third party — covering part or +> all of the cost, including **zero-deposit** services where the consumer pays nothing. See +> [subsidyProviders.md](subsidyProviders.md). + The consumer supplies the container spec directly in the request: an `image` (referenced by `tag` or `checksum`, or an inline `dockerfile` when the operator allows building), optional `dockerCmd` / `dockerEntrypoint`, the container ports to expose, the diff --git a/docs/subsidyProviders.md b/docs/subsidyProviders.md new file mode 100644 index 000000000..b5dda177c --- /dev/null +++ b/docs/subsidyProviders.md @@ -0,0 +1,192 @@ +# Subsidy Providers + +**Subsidy Providers** let a third party cover part (or all) of a consumer's payment for a paid +compute job or an on-demand service — and optionally pay the node a bonus. A subsidy provider is an +on-chain contract that holds a budget and decides, per request, how much to contribute. The Ocean +Node simply **names** one or more providers when it locks and claims the consumer's payment in +escrow; the escrow and the provider contracts do the rest. + +This unlocks flows like **zero-deposit onboarding** (a fully-sponsored job where the consumer pays +nothing), enterprise/university subsidies, promotional discounts, grants, and loyalty bonuses — +without changing how jobs are published or run. + +- Operator configuration: [`env.md`](env.md) (`SUBSIDY_PROVIDERS`, `SUBSIDY_PROVIDER_FILTER`). +- Per-request field + event queries: [`API.md`](API.md) (`subsidyProviders`, `getEscrowEvents`). + +--- + +## How it works + +Paid compute and services settle through the Ocean **Escrow** contract. The node is the **payee**: +it creates a **lock** on the consumer's funds up front, runs the work, then **claims** the lock when +the work is done (or cancels it on failure). Subsidy providers plug into that lifecycle at two +points, and the node hands the **same provider list** to both the lock and the claim so they always +agree. + +``` +consumer deposits + authorizes ──► node createLock([providers]) ──► work runs ──► node claimLock([providers]) + │ (PREPAID: provider pre-funds) │ (REFUND: provider reimburses) + └─ escrow emits LockSponsored └─ escrow emits Subsidized +``` + +A provider only contributes if, for that request, it is funded and the caller passes its gates +(see [Access-list gating](#access-list-gating)). Otherwise the lock/claim is simply payer-funded. + +--- + +## Two modes: prepaid vs refund (and zero-deposit) + +Every subsidy provider advertises which mode(s) it honours through its own `subsidyModeConfig()` +(owner-set via `setSubsidyMode`). This is a **provider** setting — the node does not choose the +mode; it only names the provider. + +| Mode value | Name | When the subsidy applies | Escrow event | +|---|---|---|---| +| `0` | `BOTH` | both legs active (default) | `LockSponsored` and/or `Subsidized` | +| `1` | `REFUND_ONLY` | **claim** time — provider reimburses after the work ran | `Subsidized` | +| `2` | `PREPAID_ONLY` | **lock** time — provider pre-funds the lock up front | `LockSponsored` | + +**REFUND (reimbursement).** The consumer still fronts the funds (they must have deposited and +authorized enough). At claim, the provider reimburses the sponsored portion. This is the classic +"cashback" model — the job is paid normally, then the subsidy flows back. + +**PREPAID (lock-time sponsorship).** The provider's tokens are pulled into a non-withdrawable +sponsored bucket that backs the lock **at creation time**. At claim, the node is paid from that +bucket first. + +**Zero-deposit onboarding.** Because a prepaid lock is backed by the provider's bucket, a +**fully-sponsored** lock needs **no consumer deposit at all**. The consumer authorizes the node with +a `maxLockedAmount` of `0` ("sponsored-only — the node can never touch my own funds"), deposits +nothing, and the provider covers 100% of the cost. On the node side this is why the sponsored-lock +pre-checks are relaxed: a sponsored request is not rejected for an empty balance (service start and +paid compute both support this). + +**Bonus.** A provider can also pay the **node** a bonus on top of the subsidy (surfaced as +`bonusAmount` on the `Subsidized` event, and per-mode via the provider's `quoteSubsidyModes`). This +is how loyalty / incentive programs reward node operators for routing work through a provider. + +--- + +## Configuring your node (operator) + +Two environment variables (full details in [`env.md`](env.md)): + +- **`SUBSIDY_PROVIDERS`** — a per-chain map of provider contract addresses the node uses by default, + e.g. `{"8453": ["0x…"]}`. The node passes these to the escrow at **lock and claim** time. +- **`SUBSIDY_PROVIDER_FILTER`** — `true`/`false` (default `false`). When ON, a consumer-supplied + provider list may only name addresses already in `SUBSIDY_PROVIDERS` for that chain; anything else + is rejected (HTTP 400). + +> ⚠️ **Open-program warning.** With the filter OFF, a consumer may name **any** funded provider — +> including one they have no relationship with — and have your node draw against it. An OFF filter +> therefore runs an **open, sybil-drainable** program, bounded only by the provider's funded balance +> and caps. The node logs a startup **WARN** when `SUBSIDY_PROVIDERS` is set while the filter is off. +> Turn the filter **ON** to restrict callers to your own providers, and fund/cap providers +> accordingly. + +The node does not deploy or own providers; it references existing provider contracts. Operators who +want to run their own program deploy a provider (e.g. one of the reference implementations below), +fund it, configure it, and list its address in `SUBSIDY_PROVIDERS`. + +--- + +## Choosing providers per request (consumer) + +`startCompute` (paid), `serviceStart`, and `serviceExtend` accept an optional top-level +`subsidyProviders` array (ignored for free compute). It overrides the node's defaults for that one +request: + +| Value | Meaning | +|---|---| +| omitted / `undefined` | use the node's configured `SUBSIDY_PROVIDERS` for the chain | +| `[]` | no providers — plain payer-funded | +| `["0x…", …]` | use exactly these addresses | + +Addresses must be valid EVM addresses (checksummed on the way through); duplicates are collapsed; at +most **10 unique** providers may apply to one lock (the escrow's `maxSponsorsPerLock()`), and more is +rejected with HTTP 400. When `SUBSIDY_PROVIDER_FILTER` is ON, every named address must be in the +node's whitelist for the chain. See the per-request `subsidyProviders` field in [`API.md`](API.md). + +--- + +## Reference provider implementations + +Two providers ship with the Ocean contracts; both implement the standard interfaces (so they are +discoverable via ERC-165) and both support REFUND, PREPAID, or BOTH. + +- **`OPFSubsidyProvider`** — a **rolling-budget** program. The owner configures per-token limits with + `setTokenLimits(token, pctBps, daily, weekly, monthly, enabled)` (a percentage cap plus + daily/weekly/monthly spend windows) and funds the contract with tokens. Good for ongoing programs: + promos, percentage discounts, regional growth funds. +- **`OneTimeSubsidyProvider`** — a **per-user credit** program. The owner configures + `setTokenConfig(token, pctBps, defaultCredit, enabled)` (a one-time credit every user gets) and/or + grants explicit credits with `setUserCredit` / `setUserCredits`, and can reset users. Good for + bounded, per-person grants: onboarding credits, hackathons, student programs. + +Common owner controls on both: `setAllowedJobTypes([...])` (which job types are subsidized), +`setAuthorizedEscrow(escrow, true)` (authorize the escrow to call it), `pause()` / `unpause()`, +`setSubsidyMode(mode)`, `withdrawTokens` / `withdrawAllTokens`, and the two access lists below. + +### Access-list gating + +Each provider applies **two** gates on every subsidy decision — a **user** access list (the payer) +and a **node** access list (the payee) — via `setUserAccessList` / `setNodeAccessList`. A **zero +address = open to everyone**, so an operator can run any of: + +- **Open** — both lists unset: anyone may draw (bounded by funding/caps; combine with + `SUBSIDY_PROVIDER_FILTER` on the node side). +- **User-gated** — only listed payers (e.g. employees, students, members). +- **Node-gated** — only listed nodes (e.g. your own fleet, partner nodes). +- **Fully-gated** — both. + +--- + +## Events & observability + +The node's indexer records these escrow events (query them via `getEscrowEvents`, see +[`API.md`](API.md)): + +| Event | Emitted when | Carries | +|---|---|---| +| `LockSponsored` | a provider pre-funds a lock (PREPAID) | `provider`, `amount` | +| `Subsidized` | a provider reimburses at claim (REFUND) | `provider`, `subsidyAmount`, `bonusAmount` | +| `SponsorRefunded` | unused prepaid tokens returned (partial claim / expiry / shrink) | `provider`, `amount`, `reclaimable` | + +Accounting note for anyone reading escrow state directly: under Escrow v2, `getUserFunds().locked` +and an authorization's `currentLockedAmount` track only the **payer-funded** portion `P = L − S` +(not the gross lock `L`); the sponsored portion `S` lives in a separate bucket +(`getSponsoredTotal(token)`, or `getSponsorship(payee, payer, jobId)` per lock). + +--- + +## Use cases + +Each of these is just a provider configured a particular way and named in `SUBSIDY_PROVIDERS` (or +supplied per request): + +| Use case | Shape | +|---|---| +| **Enterprise subsidizes its employees' compute** | OPF, **user-gated** to the employee access list; REFUND or PREPAID. | +| **Enterprise subsidizes its own servers/nodes** | OPF, **node-gated** to the company's node access list. | +| **University subsidizes students** | OneTime per-student credit (or OPF user-gated to a student list). | +| **Node owner's "Half-off Mondays" promo** | OPF with `pctBps` ≈ 5000 (50%), funded/limited for the promo window. | +| **New-user onboarding credit (first N jobs free)** | OneTime, **PREPAID** for zero-deposit, `defaultCredit` sized to N jobs. | +| **Grant / research funder as a bounded escrow** | Fund a provider with a fixed budget; subsidy stops when the pool drains. | +| **Node loyalty via `bonusAmount`** | Provider pays the node a bonus on top of the subsidy to reward routing. | +| **Tiered membership (Bronze/Silver/Gold)** | Per-user credits/limits (`setUserCredit` tiers) behind a member access list. | +| **Regional / ecosystem growth fund** | Open or region-gated OPF funded by the ecosystem; cap via windows + node filter. | +| **Token-preference incentive** | Per-token limits (`setTokenLimits`) that subsidize only the preferred token. | +| **Sponsor-a-hackathon / event mode** | OneTime `setUserCredits` to participant addresses, time-boxed by funding. | + +> Pairing tip: for **open** programs keep `SUBSIDY_PROVIDER_FILTER=true` on the nodes you want to +> favour and fund/cap the provider conservatively; for **gated** programs the provider's access +> lists do the restriction and the node filter is optional. + +--- + +## See also + +- [`env.md`](env.md) — `SUBSIDY_PROVIDERS`, `SUBSIDY_PROVIDER_FILTER`. +- [`API.md`](API.md) — per-request `subsidyProviders`, `getEscrowEvents`. +- [`compute.md`](compute.md) — paid compute environments. +- [`services.md`](services.md) — on-demand services. diff --git a/package-lock.json b/package-lock.json index 9c14eceb9..a5e4c58f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@libp2p/utils": "^7.4.1", "@libp2p/websockets": "^10.1.21", "@multiformats/multiaddr": "^13.0.3", - "@oceanprotocol/contracts": "^2.9.0", + "@oceanprotocol/contracts": "^3.2.0-rc.0", "@oceanprotocol/ddo-js": "^0.4.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", @@ -2969,9 +2969,9 @@ } }, "node_modules/@oceanprotocol/contracts": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@oceanprotocol/contracts/-/contracts-2.9.0.tgz", - "integrity": "sha512-B3dQNxIYD7bASNE066vfZu6Ik5uHZ/1c+QEcUvAsfoNvUUJ5+uQfIvhwrpdMCza1GtE11EW2glDPZvbr2LwFsg==", + "version": "3.2.0-rc.0", + "resolved": "https://registry.npmjs.org/@oceanprotocol/contracts/-/contracts-3.2.0-rc.0.tgz", + "integrity": "sha512-QBqSZz98AGqClr8HKKSmFFRrz8DvFhLT8swUWz+XbnTwgTXSnR2vg7UojLhAI10MbeSwfh37YcbGvdcnl3DRmA==", "license": "Apache-2.0" }, "node_modules/@oceanprotocol/ddo-js": { diff --git a/package.json b/package.json index b347d33ab..81667e9ee 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@libp2p/utils": "^7.4.1", "@libp2p/websockets": "^10.1.21", "@multiformats/multiaddr": "^13.0.3", - "@oceanprotocol/contracts": "^2.9.0", + "@oceanprotocol/contracts": "^3.2.0-rc.0", "@oceanprotocol/ddo-js": "^0.4.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", diff --git a/src/@types/C2D/C2D.ts b/src/@types/C2D/C2D.ts index 6da7d0b4c..a41819a5d 100644 --- a/src/@types/C2D/C2D.ts +++ b/src/@types/C2D/C2D.ts @@ -411,6 +411,11 @@ export interface DBComputeJobPayment { claimTx: string cancelTx: string cost: number + // Resolved, validated user-supplied Subsidy Provider addresses for this job's chain, snapshotted + // at request time so the (possibly async/batched) escrow claim uses the user's choice rather than + // whatever the node config holds later. undefined = fall back to node config at claim time; + // [] = claim with no subsidy providers; non-empty = claim with exactly these. + subsidyProviders?: string[] } // this is the internal structure diff --git a/src/@types/Escrow.ts b/src/@types/Escrow.ts index 3b248a9ae..531cc496d 100644 --- a/src/@types/Escrow.ts +++ b/src/@types/Escrow.ts @@ -5,6 +5,9 @@ export interface EscrowAuthorization { maxLockSeconds: BigInt maxLockCounts: BigInt currentLocks: BigInt + // Escrow v2: 0 = indefinite, >0 = unix ts after which the payee can no longer create/extend + // locks (claim/cancel are never gated). Present on v2 escrows only. + expiryTimestamp?: BigInt } export interface EscrowLock { @@ -35,4 +38,12 @@ export interface EscrowEvent { oldAmount?: string newAmount?: string newExpiry?: string + // Subsidized event fields + provider?: string + subsidyAmount?: string + bonusAmount?: string + // Auth event (Escrow v2): authorization expiry (0 = indefinite) + expiryTimestamp?: string + // SponsorRefunded event (Escrow v2): true => push failed, parked for sweep + reclaimable?: boolean } diff --git a/src/@types/OceanNode.ts b/src/@types/OceanNode.ts index 196708031..6cf8a7b50 100644 --- a/src/@types/OceanNode.ts +++ b/src/@types/OceanNode.ts @@ -177,6 +177,11 @@ export interface OceanNodeConfig { assetPurgatoryUrl: string | null allowedAdmins?: string[] allowedAdminsList?: AccessListContract | null + // per-chain map of Subsidy Provider contract addresses passed to the escrow at claim time + subsidyProviders?: AccessListContract | null + // when ON, a user-supplied subsidy-provider list may only contain addresses already present in + // `subsidyProviders` for the request chain; otherwise the request is rejected. Default false. + subsidyProviderFilter?: boolean codeHash?: string rateLimit?: number // per request ip or peer maxConnections?: number // global, regardless of client address(es) @@ -264,6 +269,10 @@ export interface OceanNodeStatus { uptime?: number // seconds since start codeHash?: string allowedAdmins?: { addresses: string[]; accessLists: AccessListContract } + // per-chain map of Subsidy Provider contract addresses configured on this node + subsidyProviders?: AccessListContract + // whether this node restricts user-supplied subsidy providers to the configured whitelist + subsidyProviderFilter?: boolean // detailed information c2dClusters?: any[] supportedSchemas?: Schema[] diff --git a/src/@types/commands.ts b/src/@types/commands.ts index 2201e51f3..192b5e9fe 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -278,6 +278,10 @@ export interface FreeComputeStartCommand extends Command { additionalViewers?: string[] // addresses of additional addresses that can get results queueMaxWaitTime?: number // max time in seconds a job can wait in the queue before being started encryptedDockerRegistryAuth?: string + // Optional user-supplied Subsidy Provider contract addresses for the request chain. undefined = + // use the node config; [] = no providers; non-empty = use ONLY these (ignoring node config). + // Ignored for free compute (no escrow claim). See resolveUserSubsidyProviders. + subsidyProviders?: string[] } export interface PaidComputeStartCommand extends FreeComputeStartCommand { payment: ComputePayment @@ -454,6 +458,10 @@ export interface ServiceStartCommand extends Command { metadata?: DBComputeJobMetadata // optional user-defined labels for the service; node-opaque, ≤1 KB outputBucketId?: string // persistent-storage bucket bind-mounted at /data/outputs payment: { chainId: number; token: string } + // Optional user-supplied Subsidy Provider contract addresses for payment.chainId. undefined = + // use the node config; [] = no providers; non-empty = use ONLY these. See + // resolveUserSubsidyProviders. + subsidyProviders?: string[] } export interface ServiceStopCommand extends Command { @@ -546,4 +554,8 @@ export interface ServiceExtendCommand extends Command { serviceId: string additionalDuration: number payment: { chainId: number; token: string } + // Optional user-supplied Subsidy Provider contract addresses for payment.chainId. undefined = + // use the node config; [] = no providers; non-empty = use ONLY these. See + // resolveUserSubsidyProviders. + subsidyProviders?: string[] } diff --git a/src/OceanNode.ts b/src/OceanNode.ts index 9098b796d..9896435ff 100644 --- a/src/OceanNode.ts +++ b/src/OceanNode.ts @@ -79,7 +79,8 @@ export class OceanNode { this.escrow = new Escrow( this.config.supportedNetworks, this.config.claimDurationTimeout, - this.blockchainRegistry + this.blockchainRegistry, + this.config.subsidyProviders ) if (this.config.persistentStorage?.enabled) { OCEAN_NODE_LOGGER.info( @@ -281,7 +282,8 @@ export class OceanNode { this.escrow = new Escrow( this.config.supportedNetworks, this.config.claimDurationTimeout, - this.blockchainRegistry + this.blockchainRegistry, + this.config.subsidyProviders ) } } diff --git a/src/components/Indexer/processor.ts b/src/components/Indexer/processor.ts index 051008351..0ddd25c02 100644 --- a/src/components/Indexer/processor.ts +++ b/src/components/Indexer/processor.ts @@ -28,7 +28,7 @@ import ERC20Template from '@oceanprotocol/contracts/artifacts/contracts/template import AccessListContract from '@oceanprotocol/contracts/artifacts/contracts/accesslists/AccessList.sol/AccessList.json' with { type: 'json' } import { OceanNodeConfig } from '../../@types/OceanNode.js' -const EVENT_PROCESSOR_MAP: Record = { +export const EVENT_PROCESSOR_MAP: Record = { [EVENTS.METADATA_CREATED]: MetadataEventProcessor, [EVENTS.METADATA_UPDATED]: MetadataEventProcessor, [EVENTS.METADATA_STATE]: MetadataStateEventProcessor, @@ -50,7 +50,10 @@ const EVENT_PROCESSOR_MAP: Record = { [EVENTS.ESCROW_CLAIMED]: EscrowEventProcessor, [EVENTS.ESCROW_CANCELED]: EscrowEventProcessor, [EVENTS.ESCROW_DEPOSIT]: EscrowEventProcessor, - [EVENTS.ESCROW_WITHDRAW]: EscrowEventProcessor + [EVENTS.ESCROW_WITHDRAW]: EscrowEventProcessor, + [EVENTS.ESCROW_SUBSIDIZED]: EscrowEventProcessor, + [EVENTS.ESCROW_LOCK_SPONSORED]: EscrowEventProcessor, + [EVENTS.ESCROW_SPONSOR_REFUNDED]: EscrowEventProcessor } const processorInstances = new Map() diff --git a/src/components/Indexer/processors/EscrowEventProcessor.ts b/src/components/Indexer/processors/EscrowEventProcessor.ts index d23ae4340..607927191 100644 --- a/src/components/Indexer/processors/EscrowEventProcessor.ts +++ b/src/components/Indexer/processors/EscrowEventProcessor.ts @@ -61,6 +61,9 @@ export class EscrowEventProcessor extends BaseEventProcessor { record.maxLockedAmount = num(args.maxLockedAmount) record.maxLockSeconds = num(args.maxLockSeconds) record.maxLockCounts = num(args.maxLockCounts) + // Escrow v2: 0 = indefinite, >0 = unix ts after which the payee can no longer + // create/extend locks. Indexed only (not enforced node-side). + record.expiryTimestamp = num(args.expiryTimestamp) break case EVENTS.ESCROW_LOCK: record.payer = addr(args.payer) @@ -100,6 +103,36 @@ export class EscrowEventProcessor extends BaseEventProcessor { record.token = addr(args.token) record.amount = num(args.amount) break + case EVENTS.ESCROW_SUBSIDIZED: + // Subsidized(payee, payer, jobId, token, provider, subsidyAmount, bonusAmount) + record.payee = addr(args.payee) + record.payer = addr(args.payer) + record.jobId = num(args.jobId) + record.token = addr(args.token) + record.provider = addr(args.provider) + record.subsidyAmount = num(args.subsidyAmount) + record.bonusAmount = num(args.bonusAmount) + break + case EVENTS.ESCROW_LOCK_SPONSORED: + // LockSponsored(payer, payee, jobId, token, provider, amount) + record.payer = addr(args.payer) + record.payee = addr(args.payee) + record.jobId = num(args.jobId) + record.token = addr(args.token) + record.provider = addr(args.provider) + record.amount = num(args.amount) + break + case EVENTS.ESCROW_SPONSOR_REFUNDED: + // SponsorRefunded(payer, payee, jobId, token, provider, amount, reclaimable) + // reclaimable=true => push failed, parked for the provider to sweep. + record.payer = addr(args.payer) + record.payee = addr(args.payee) + record.jobId = num(args.jobId) + record.token = addr(args.token) + record.provider = addr(args.provider) + record.amount = num(args.amount) + record.reclaimable = Boolean(args.reclaimable) + break default: return null } diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index c8da48e51..552250a69 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -51,7 +51,7 @@ import { } from 'fs' import { pipeline } from 'node:stream/promises' import { CORE_LOGGER } from '../../utils/logging/common.js' -import { ENVIRONMENT_VARIABLES } from '../../utils/constants.js' +import { ENVIRONMENT_VARIABLES, JobType } from '../../utils/constants.js' import { AssetUtils } from '../../utils/asset.js' import { FindDdoHandler } from '../core/handler/ddoHandler.js' import { OceanNode } from '../../OceanNode.js' @@ -1004,6 +1004,10 @@ export class C2DEngineDocker extends C2DEngine { const payers = jobs.map((j) => j.owner) const amounts = claims.map((c) => c.cost) const proofs = claims.map((c) => c.proof) + // Per-job subsidy overrides. An explicit user list ([] or non-empty) was frozen onto the + // job at request time and is passed through unchanged; `undefined`/null here means the + // user opted out, so the escrow falls back to the node config read live at claim time. + const subsidyOverrides = jobs.map((j) => j.payment!.subsidyProviders ?? null) const txId = await this.escrow.claimLocks( chainId, @@ -1011,7 +1015,9 @@ export class C2DEngineDocker extends C2DEngine { tokens, payers, amounts, - proofs + proofs, + JobType.COMPUTE, + subsidyOverrides ) if (txId) { // Update all jobs with the transaction ID @@ -1041,7 +1047,9 @@ export class C2DEngineDocker extends C2DEngine { claim.job.payment!.token, claim.job.owner, claim.cost, - claim.proof + claim.proof, + JobType.COMPUTE, + claim.job.payment!.subsidyProviders ?? null ) if (txId) { if (claim.job.payment) { @@ -4065,7 +4073,11 @@ export class C2DEngineDocker extends C2DEngine { token, job.owner, job.payment.cost, - this.escrow.getMinLockTime(job.duration) + this.escrow.getMinLockTime(job.duration), + JobType.SERVICE, + // Same subsidy-provider list the matching claimLock uses (see below) — lock & claim + // must agree, so the sponsored lock can be settled from the same providers. + job.payment.subsidyProviders ?? null ) if (!lockTx) throw new Error('Escrow lock failed') await this.escrow.waitForTransaction(chainId, lockTx) @@ -4133,7 +4145,9 @@ export class C2DEngineDocker extends C2DEngine { token, job.owner, job.payment.cost, - `service-start:${serviceId}` + `service-start:${serviceId}`, + JobType.SERVICE, + job.payment.subsidyProviders ?? null ) if (!claimTx) { job.payment.cancelTx = await this.safeCancelLock( diff --git a/src/components/core/compute/startCompute.ts b/src/components/core/compute/startCompute.ts index 9f931d5ff..4ed661fcb 100644 --- a/src/components/core/compute/startCompute.ts +++ b/src/components/core/compute/startCompute.ts @@ -48,6 +48,8 @@ import { checkAddressOnAccessList } from '../../../utils/accessList.js' import { ensureConsumerAllowedForPersistentStorageLocalfsFileObject } from '../../persistentStorage/PersistentStorageFactory.js' import { resolveComputeFileObject } from '../../c2d/compute_engine_docker.js' import { cJobsStarted } from '../../../telemetry/metrics.js' +import { resolveUserSubsidyProviders } from '../utils/subsidyProviders.js' +import { JobType } from '../../../utils/constants.js' export class CommonComputeHandler extends CommandHandler { validate(command: PaidComputeStartCommand): ValidateParams { @@ -581,6 +583,35 @@ export class PaidComputeStartHandler extends CommonComputeHandler { task.payment.token, task.maxJobDuration ) + // Resolve the user-supplied subsidy providers (if any) for the payment chain, enforcing the + // node's whitelist filter when enabled. Done before locking funds so a bad list fails fast; + // the resolved value is persisted on the job so the (batched) claim later uses the user's + // choice rather than whatever the node config holds at claim time. + const subsidyResolution = resolveUserSubsidyProviders( + task.subsidyProviders, + task.payment.chainId, + node.getConfig() + ) + if (!subsidyResolution.valid) { + return { + stream: null, + status: { + httpStatus: 400, + error: subsidyResolution.reason + } + } + } + // Snapshot the EFFECTIVE provider list now and reuse the SAME array for both the lock and the + // (batched) claim, so they can never disagree. An explicit user list is used as-is; when the + // user opted out (`resolved === undefined`) we snapshot the node's configured list for this + // chain AT LOCK TIME, rather than letting the lock and the much-later claim each re-read + // SUBSIDY_PROVIDERS live — for a prefunded lock those two live reads could straddle an + // operator config change and settle the claim against a different provider set than the lock + // was funded from. `??` (not `||`) so an explicit `[]` (no providers) survives. + const effectiveSubsidy = + subsidyResolution.resolved ?? + node.getConfig().subsidyProviders?.[String(task.payment.chainId)] ?? + [] let agreementId CORE_LOGGER.logMessage( `escrow.createLock cost=${cost} token=${task.payment.token} chainId=${task.payment.chainId} resources=${JSON.stringify(task.resources)} maxJobDuration=${task.maxJobDuration}`, @@ -595,7 +626,11 @@ export class PaidComputeStartHandler extends CommonComputeHandler { cost, engine.escrow.getMinLockTime( Number(task.maxJobDuration) + Number(task.queueMaxWaitTime) - ) + ), + JobType.COMPUTE, + // The snapshotted list, also persisted on the job (below) and handed to claimLock — + // lock & claim settle from the exact same provider set. + effectiveSubsidy ) } catch (e) { const errMsg = e?.message || String(e) @@ -646,7 +681,8 @@ export class PaidComputeStartHandler extends CommonComputeHandler { lockTx: agreementId, claimTx: null, cancelTx: null, - cost: 0 + cost: 0, + subsidyProviders: effectiveSubsidy }, jobId, task.metadata, diff --git a/src/components/core/service/extendService.ts b/src/components/core/service/extendService.ts index 15c952efa..99dc8e724 100644 --- a/src/components/core/service/extendService.ts +++ b/src/components/core/service/extendService.ts @@ -9,10 +9,12 @@ import { buildInvalidRequestMessage } from '../../httpRoutes/validateCommands.js' import { CORE_LOGGER } from '../../../utils/logging/common.js' +import { JobType } from '../../../utils/constants.js' import type { ComputeEnvironment } from '../../../@types/C2D/C2D.js' import { ServiceStatusNumber } from '../../../@types/C2D/ServiceOnDemand.js' import { validateAccess } from '../compute/startCompute.js' import { findServiceJobAndEngine, toPublicServiceJob } from './utils.js' +import { resolveUserSubsidyProviders } from '../utils/subsidyProviders.js' export class ServiceExtendHandler extends CommandHandler { validate(command: ServiceExtendCommand): ValidateParams { @@ -187,6 +189,19 @@ export class ServiceExtendHandler extends CommandHandler { ) ) + // Resolve the user-supplied subsidy providers (if any) for the payment chain, enforcing + // the node's whitelist filter when enabled. The extend claim is synchronous, so the + // resolved value is passed straight to claimLock below rather than persisted. + const subsidyResolution = resolveUserSubsidyProviders( + task.subsidyProviders, + task.payment.chainId, + this.getOceanNode().getConfig() + ) + if (!subsidyResolution.valid) + return buildInvalidParametersResponse( + buildInvalidRequestMessage(subsidyResolution.reason) + ) + // An extendPayments entry with a lockTx but neither claimTx nor cancelTx is an // UNRESOLVED intent from a previous crash (see below — the intent is persisted // before claiming). Resolve it before charging again: try to cancel (refund) @@ -233,7 +248,11 @@ export class ServiceExtendHandler extends CommandHandler { task.payment.token, task.consumerAddress, costExtend, - engine.escrow.getMinLockTime(task.additionalDuration) + engine.escrow.getMinLockTime(task.additionalDuration), + JobType.SERVICE, + // Same subsidy-provider list the in-scope claimLock uses below — lock & claim + // must agree on the sponsoring providers. + subsidyResolution.resolved ?? null ) } catch (e: any) { CORE_LOGGER.error(`Service extend createLock failed: ${e.message}`) @@ -273,6 +292,11 @@ export class ServiceExtendHandler extends CommandHandler { // claiming: a crash between claim and the final write is then auditable — the // consumer's money can never be taken without a durable record of why — and a // retry finds the intent (unresolved branch above) instead of charging twice. + // NOTE: the intent does NOT carry the resolved subsidy providers. That is safe today + // because the claim is synchronous and uses the in-scope `subsidyResolution.resolved` + // moments later. If this claim is ever made async/crash-recoverable (like the compute + // batch), persist the resolved list here so the recovery path can reclaim with the + // consumer's original choice instead of silently falling back to node config. const intent = { chainId: task.payment.chainId, token: task.payment.token, @@ -330,7 +354,9 @@ export class ServiceExtendHandler extends CommandHandler { task.payment.token, task.consumerAddress, costExtend, - `service-extend:${task.serviceId}` + `service-extend:${task.serviceId}`, + JobType.SERVICE, + subsidyResolution.resolved ?? null ) } catch (e: any) { claimTx = null diff --git a/src/components/core/service/startService.ts b/src/components/core/service/startService.ts index 49856d6d4..20c2396fa 100644 --- a/src/components/core/service/startService.ts +++ b/src/components/core/service/startService.ts @@ -19,6 +19,7 @@ import { generateUniqueID, validateOutputBucket } from '../compute/utils.js' import { validateAccess } from '../compute/startCompute.js' import { isJobMetadataSizeValid, INVALID_JOB_METADATA_MESSAGE } from '../../c2d/index.js' import { decryptUserData, toPublicServiceJob } from './utils.js' +import { resolveUserSubsidyProviders } from '../utils/subsidyProviders.js' export class ServiceStartHandler extends CommandHandler { validate(command: ServiceStartCommand): ValidateParams { @@ -196,37 +197,63 @@ export class ServiceStartHandler extends CommandHandler { ) ) + // Resolve the user-supplied subsidy providers (if any) for the payment chain BEFORE the + // funds pre-check, so a sponsored request can skip it. Snapshot the EFFECTIVE list (the + // explicit user list, else the node's configured list for this chain) and persist it on the + // job, so the background lock and the later claim settle from the exact same provider set + // instead of each re-reading config. `??` (not `||`) so an explicit `[]` survives. + const subsidyResolution = resolveUserSubsidyProviders( + task.subsidyProviders, + task.payment.chainId, + node.getConfig() + ) + if (!subsidyResolution.valid) { + return buildInvalidParametersResponse( + buildInvalidRequestMessage(subsidyResolution.reason) + ) + } + const effectiveSubsidy = + subsidyResolution.resolved ?? + node.getConfig().subsidyProviders?.[String(task.payment.chainId)] ?? + [] + const isSponsored = effectiveSubsidy.length > 0 + // 6b. Fail fast when the consumer's escrow visibly can't cover the cost, instead // of returning a serviceId doomed to fail asynchronously at the Locking step. // Best-effort UX only: balances can change before the background createLock runs, // so the authoritative check stays in the pipeline — and an RPC hiccup here must // not block starts (the pipeline check will catch a genuine shortfall anyway). - try { - const [availableWei, costWei] = await Promise.all([ - engine.escrow.getUserAvailableFunds( - task.payment.chainId, - task.consumerAddress, - task.payment.token - ), - engine.escrow.getPaymentAmountInWei( - cost, - task.payment.chainId, - task.payment.token - ) - ]) - if (BigInt(availableWei.toString()) < BigInt(costWei.toString())) { - return buildInvalidParametersResponse( - buildInvalidRequestMessage( - `Insufficient escrow funds for token ${task.payment.token} on chain ` + - `${task.payment.chainId}: available ${availableWei}, required ${costWei} ` + - `wei — deposit and authorize escrow funds before starting the service` + // Skipped for a sponsored request: a (partly) sponsored lock only needs the payer to + // cover the unsponsored portion — a fully-sponsored user may hold 0 — so this mirrors + // the createLock stopgap and lets zero-deposit Service-on-Demand through. + if (!isSponsored) { + try { + const [availableWei, costWei] = await Promise.all([ + engine.escrow.getUserAvailableFunds( + task.payment.chainId, + task.consumerAddress, + task.payment.token + ), + engine.escrow.getPaymentAmountInWei( + cost, + task.payment.chainId, + task.payment.token + ) + ]) + if (BigInt(availableWei.toString()) < BigInt(costWei.toString())) { + return buildInvalidParametersResponse( + buildInvalidRequestMessage( + `Insufficient escrow funds for token ${task.payment.token} on chain ` + + `${task.payment.chainId}: available ${availableWei}, required ${costWei} ` + + `wei — deposit and authorize escrow funds before starting the service` + ) ) + } + } catch (e: any) { + CORE_LOGGER.debug( + `SERVICE_START: escrow funds pre-check skipped (${e.message}) — the background Locking step will verify` ) } - } catch (e: any) { - CORE_LOGGER.debug( - `SERVICE_START: escrow funds pre-check skipped (${e.message}) — the background Locking step will verify` - ) } const serviceId = generateUniqueID({ @@ -244,7 +271,8 @@ export class ServiceStartHandler extends CommandHandler { lockTx: '', claimTx: '', cancelTx: '', - cost + cost, + subsidyProviders: effectiveSubsidy } // 7. Persist the Starting record and return immediately with the serviceId. The diff --git a/src/components/core/utils/escrow.ts b/src/components/core/utils/escrow.ts index 0d438f7ff..aba756f75 100644 --- a/src/components/core/utils/escrow.ts +++ b/src/components/core/utils/escrow.ts @@ -4,6 +4,8 @@ import EscrowJson from '@oceanprotocol/contracts/artifacts/contracts/escrow/Escr import { EscrowAuthorization, EscrowLock } from '../../../@types/Escrow.js' import { getOceanArtifactsAdressesByChainId } from '../../../utils/address.js' import { RPCS } from '../../../@types/blockchain.js' +import { AccessListContract } from '../../../@types/OceanNode.js' +import { JobType } from '../../../utils/constants.js' import { create256Hash } from '../../../utils/crypt.js' import { sleep } from '../../../utils/util.js' import { BlockchainRegistry } from '../../BlockchainRegistry/index.js' @@ -17,17 +19,29 @@ export class Escrow { private networks: RPCS private claimDurationTimeout: number private blockchainRegistry: BlockchainRegistry + /** Per-chain Subsidy Provider contract addresses, passed to the escrow at claim time. */ + private subsidyProviders: AccessListContract | null /** Cache for token decimals to avoid repeated blockchain calls */ private decimalsCache: Map = new Map() constructor( supportedNetworks: RPCS, claimDurationTimeout: number, - blockchainRegistry: BlockchainRegistry + blockchainRegistry: BlockchainRegistry, + subsidyProviders: AccessListContract | null = null ) { this.networks = supportedNetworks this.claimDurationTimeout = claimDurationTimeout this.blockchainRegistry = blockchainRegistry + this.subsidyProviders = subsidyProviders + } + + /** + * Subsidy Provider contract addresses configured for a given chain, or an empty list when none + * are set. The empty list is the "plain claim" case the escrow expects (no third-party subsidy). + */ + private getSubsidyProvidersForChain(chain: number): string[] { + return this.subsidyProviders?.[String(chain)] ?? [] } getEscrowContractAddressForChain(chainId: number): string | null { @@ -163,18 +177,46 @@ export class Escrow { token: string, payer: string, amount: number, - expiry: BigNumberish + expiry: BigNumberish, + jobType: JobType = JobType.NONE, + subsidyOverride: string[] | null = null ): Promise { const jobId = create256Hash(job) + // Escrow v2 `createLock` takes `jobType` + `subsidyProviders` (lock-time / "prepaid" + // sponsorship). The node hands the escrow the SAME provider list at lock time as it does at + // claim time, resolved identically to `claimLock`: a user-supplied override wins, otherwise + // the per-chain node config is used. `??` (not `||`) so a user-supplied empty list survives + // as "no providers" (a plain payer-funded lock, identical to the pre-v2 behaviour). + const subsidyProviders = subsidyOverride ?? this.getSubsidyProvidersForChain(chain) const blockchain = this.getBlockchain(chain) const signer = await blockchain.getSigner() const contract = this.getContract(chain, signer) if (!contract) throw new Error(`Failed to initialize escrow contract`) const wei = await this.getPaymentAmountInWei(amount, chain, token) - const userBalance = await this.getUserAvailableFunds(chain, payer, token) - if (BigInt(userBalance.toString()) < BigInt(wei)) { - // not enough funds - throw new Error(`User ${payer} does not have enough funds`) + + // Escrow v2 stopgap: when the lock is (partly) sponsored, the payer only needs to cover the + // UNsponsored portion `P = L - S` — a fully-sponsored lock needs 0 from the payer. We can't + // know `S` node-side without quoting the providers, so for a sponsored lock we skip the + // payer-funded pre-checks (available-funds + the `maxLockedAmount` cap) and let the on-chain + // `createLock` reject authoritatively (e.g. "Payer does not have enough funds"). A plain + // payer-funded lock (empty provider list) keeps the original fail-fast guards unchanged. + // TODO(escrow-v2 follow-up): tighten this by quoting the providers' PREFUNDED subsidy + // (`quoteSubsidyByMode`) to compute `P = L - S` and re-apply the guards against `P` — today + // a node with a global SUBSIDY_PROVIDERS list skips the fast-fail even when providers end up + // covering 0 (S=0), so an underfunded payer gets an on-chain revert instead of a clean error. + const isSponsored = subsidyProviders.length > 0 + if (isSponsored) { + CORE_LOGGER.debug( + `createLock: sponsored lock (providers=${subsidyProviders.length}) — skipping payer-funded available-funds + maxLockedAmount guards; contract settles P = L - S authoritatively.` + ) + } + + if (!isSponsored) { + const userBalance = await this.getUserAvailableFunds(chain, payer, token) + if (BigInt(userBalance.toString()) < BigInt(wei)) { + // not enough funds + throw new Error(`User ${payer} does not have enough funds`) + } } const signerAddress = await signer.getAddress() @@ -204,9 +246,14 @@ export class Escrow { } authorizations.` ) } + // Payer-funded cap check — skipped for sponsored locks (see the note above). In v2 both + // `currentLockedAmount` and `maxLockedAmount` track only the payer portion `P`, so comparing + // them against the gross `wei` would wrongly reject (a `maxLockedAmount == 0` "sponsored-only" + // auth always would). The contract enforces the real cap on `P`. if ( + !isSponsored && BigInt(auths[0].currentLockedAmount.toString()) + BigInt(wei) > - BigInt(auths[0].maxLockedAmount.toString()) + BigInt(auths[0].maxLockedAmount.toString()) ) { throw new Error(`No valid escrow auths found(will go over limit)`) } @@ -219,10 +266,53 @@ export class Escrow { ) { throw new Error(`No valid escrow auths found(too many active locks)`) } + // Auth expiry (Escrow v2): a non-zero `expiryTimestamp` is a unix ts after which the payee can + // no longer create (or extend) locks, and a lock may not be created with an end beyond it (a + // lock can never outlive its auth). The contract reverts with "Auth expired" in both cases, so + // fail fast here instead of sending a doomed tx. `0`/undefined = indefinite (also the case on + // a pre-v2 escrow whose auth tuple has no `expiryTimestamp`), so the check is a no-op there. + // This gate applies to every lock, sponsored or not (claim/cancel are never expiry-gated). + // This is an OPTIMISTIC pre-check: it uses the node's wall clock (`Date.now()`), whereas the + // contract uses the mine-time `block.timestamp`, so the on-chain revert stays authoritative. + // The bounds mirror the contract (`block.timestamp <= expiry` and `block.timestamp + duration + // <= expiry`), so equality is allowed on both. + const { expiryTimestamp } = auths[0] + if (expiryTimestamp !== undefined && expiryTimestamp !== null) { + const expiryTs = BigInt(expiryTimestamp.toString()) + if (expiryTs > 0n) { + const nowSec = BigInt(Math.floor(Date.now() / 1000)) + if (nowSec > expiryTs) { + throw new Error(`No valid escrow auths found(authorization expired)`) + } + // Lock end ≈ now + duration (the contract stamps startTime at mine time ≈ now). + if (nowSec + BigInt(expiry) > expiryTs) { + throw new Error( + `No valid escrow auths found(lock would outlive authorization expiry)` + ) + } + } + } try { - const gas = await contract.createLock.estimateGas(jobId, token, payer, wei, expiry) + const gas = await contract.createLock.estimateGas( + jobId, + token, + payer, + wei, + expiry, + jobType, + subsidyProviders + ) const gasOptions = await blockchain.getGasOptions(gas, 1.2) - const tx = await contract.createLock(jobId, token, payer, wei, expiry, gasOptions) + const tx = await contract.createLock( + jobId, + token, + payer, + wei, + expiry, + jobType, + subsidyProviders, + gasOptions + ) return tx.hash } catch (e) { CORE_LOGGER.error('Failed to create lock: ' + e.message) @@ -236,13 +326,18 @@ export class Escrow { token: string, payer: string, amount: number, - proof: string + proof: string, + jobType: JobType = JobType.NONE, + subsidyOverride: string[] | null = null ): Promise { const blockchain = this.getBlockchain(chain) const signer = await blockchain.getSigner() const contract = this.getContract(chain, signer) const wei = await this.getPaymentAmountInWei(amount, chain, token) const jobId = create256Hash(job) + // `??` (not `||`) so a user-supplied empty list means "no providers" and only a missing + // override (undefined/null) falls back to the per-chain node config. + const subsidyProviders = subsidyOverride ?? this.getSubsidyProvidersForChain(chain) if (!contract) return null try { const locks = await this.getLocks(chain, token, payer, await signer.getAddress()) @@ -253,7 +348,9 @@ export class Escrow { token, payer, wei, - ethers.toUtf8Bytes(proof) + ethers.toUtf8Bytes(proof), + jobType, + subsidyProviders ) const gasOptions = await blockchain.getGasOptions(gas, 1.2) const tx = await contract.claimLockAndWithdraw( @@ -262,6 +359,8 @@ export class Escrow { payer, wei, ethers.toUtf8Bytes(proof), + jobType, + subsidyProviders, gasOptions ) return tx.hash @@ -321,7 +420,9 @@ export class Escrow { tokens: string[], payers: string[], amounts: number[], - proofs: string[] + proofs: string[], + jobType: JobType = JobType.NONE, + subsidyOverrides: (string[] | null)[] | null = null ): Promise { const blockchain = this.getBlockchain(chain) const signer = await blockchain.getSigner() @@ -345,13 +446,24 @@ export class Escrow { jobIds.push(jobId) ethProofs.push(ethers.toUtf8Bytes(proofs[i])) } + // Parallel arrays the plural claim ABI expects: one jobType per job (all the same here) and + // one subsidy-provider list per job. Each job may carry its own user-supplied override; where + // it doesn't (undefined/null), the per-chain node config is used. `??` (not `||`) so a + // user-supplied empty list survives as "no providers". + const chainSubsidyProviders = this.getSubsidyProvidersForChain(chain) + const jobTypes: JobType[] = jobs.map(() => jobType) + const subsidyProviders: string[][] = jobs.map( + (_job, i) => subsidyOverrides?.[i] ?? chainSubsidyProviders + ) try { const gas = await contract.claimLocksAndWithdraw.estimateGas( jobIds, tokens, payers, weis, - ethProofs + ethProofs, + jobTypes, + subsidyProviders ) const gasOptions = await blockchain.getGasOptions(gas, 1.2) const tx = await contract.claimLocksAndWithdraw( @@ -360,6 +472,8 @@ export class Escrow { payers, weis, ethProofs, + jobTypes, + subsidyProviders, gasOptions ) return tx.hash diff --git a/src/components/core/utils/statusHandler.ts b/src/components/core/utils/statusHandler.ts index 23ecc295b..0a56b4707 100644 --- a/src/components/core/utils/statusHandler.ts +++ b/src/components/core/utils/statusHandler.ts @@ -148,6 +148,11 @@ export async function status( ) } } + // Per-chain Subsidy Provider contract addresses configured on this node (empty map when none + // are set). Set on every request, independent of supportedNetworks, so the field is always + // present and never retains a stale value the config no longer defines. + nodeStatus.subsidyProviders = config.subsidyProviders ?? {} + nodeStatus.subsidyProviderFilter = config.subsidyProviderFilter ?? false // Whether the P2P interface is usable, not just enabled. Re-read on every request rather // than cached with the block above: the routing table fills after startup, so a value // captured once would report a node as permanently not-ready. diff --git a/src/components/core/utils/subsidyProviders.ts b/src/components/core/utils/subsidyProviders.ts new file mode 100644 index 000000000..8f63adfd3 --- /dev/null +++ b/src/components/core/utils/subsidyProviders.ts @@ -0,0 +1,106 @@ +import { isAddress, getAddress } from 'ethers' +import { OceanNodeConfig } from '../../../@types/OceanNode.js' + +// The Escrow v2 contract caps the number of UNIQUE sponsors per lock at `maxSponsorsPerLock()` +// (== 10) and reverts `createLock`/`reLock` with "Too many sponsors" beyond it. We mirror that +// limit node-side so an over-long list is rejected cheaply (a 400) instead of costing an +// `estimateGas` round-trip on a guaranteed on-chain revert. Kept in sync with the contract +// constant; if the contract ever changes it, update here (or read it on-chain). +export const MAX_SUBSIDY_PROVIDERS_PER_LOCK = 10 + +export interface ResolvedSubsidyProviders { + valid: boolean + // Present only when valid === false: a human-readable reason for the rejection. + reason?: string + // Present only when valid === true. The resolved override to persist on the job and hand to the + // escrow claim: + // undefined → the user sent nothing; fall back to the node config at claim time + // [] → the user explicitly wants NO subsidy providers + // non-empty → use exactly these (EIP-55 checksummed) addresses, ignoring node config + resolved?: string[] +} + +/** + * Resolve and validate a user-supplied subsidy-provider list for a single-chain request + * (startCompute / startService / serviceExtend). + * + * Semantics of `userList`: + * - `undefined` → the user opted out; the node config is used (returned as `resolved: undefined`) + * - `[]` → the user wants no providers (returned as `resolved: []`) + * - non-empty → the user picks exactly these providers (returned checksummed) + * + * Snapshot vs live-config asymmetry (important for async/batched settlement): an *explicit* user + * choice (`[]` or a non-empty list) is a concrete value that callers persist on the job and feed to + * the escrow claim unchanged — it is frozen at request time. The `undefined` case persists nothing; + * the escrow then falls back to the node's `SUBSIDY_PROVIDERS` read *live* at claim time, which for + * a batched/async compute claim may run minutes-to-hours later and reflect a different node config + * than the one this request was validated against. That matches the pre-existing node-config-only + * behavior; only an explicit user list is guaranteed to survive a mid-flight config change. + * + * Every supplied address must be a valid EVM address. When `SUBSIDY_PROVIDER_FILTER` is ON, every + * supplied address must also be in the node's `subsidyProviders` whitelist for `chainId`; any + * address outside the whitelist rejects the whole request. An empty list is always allowed (it is + * a subset of any whitelist). + */ +export function resolveUserSubsidyProviders( + userList: string[] | undefined, + chainId: number, + config: OceanNodeConfig +): ResolvedSubsidyProviders { + // No list supplied → fall back to node config at claim time. + if (userList === undefined || userList === null) { + return { valid: true, resolved: undefined } + } + if (!Array.isArray(userList)) { + return { valid: false, reason: 'subsidyProviders must be an array of addresses' } + } + + // Validate + normalize every entry to its checksummed form. + const invalid: string[] = [] + const normalized: string[] = [] + for (const addr of userList) { + if (typeof addr !== 'string' || !isAddress(addr)) { + invalid.push(String(addr)) + continue + } + normalized.push(getAddress(addr)) + } + if (invalid.length > 0) { + return { + valid: false, + reason: `Invalid subsidy provider address(es): ${invalid.join(', ')}` + } + } + + // Collapse duplicates: the escrow counts UNIQUE sponsors, so a repeated address is a single + // sponsor. De-duping here keeps the persisted/forwarded list minimal and makes the cap below + // count the same way the contract does. + const deduped = Array.from(new Set(normalized)) + + // Enforce the contract's per-lock sponsor cap locally (see MAX_SUBSIDY_PROVIDERS_PER_LOCK). + if (deduped.length > MAX_SUBSIDY_PROVIDERS_PER_LOCK) { + return { + valid: false, + reason: `Too many subsidy providers: ${deduped.length} unique (max ${MAX_SUBSIDY_PROVIDERS_PER_LOCK} per lock)` + } + } + + // Whitelist enforcement: only addresses already configured on this node (for the request chain) + // are allowed through when the filter is ON. + if (config.subsidyProviderFilter) { + const whitelist = new Set( + (config.subsidyProviders?.[String(chainId)] ?? []).map((addr) => getAddress(addr)) + ) + const disallowed = deduped.filter((addr) => !whitelist.has(addr)) + if (disallowed.length > 0) { + return { + valid: false, + reason: `Subsidy provider(s) not allowed by this node for chain ${chainId}: ${disallowed.join( + ', ' + )}` + } + } + } + + return { valid: true, resolved: deduped } +} diff --git a/src/components/database/ElasticSchemas.ts b/src/components/database/ElasticSchemas.ts index ac5c8ab23..565a68ab6 100644 --- a/src/components/database/ElasticSchemas.ts +++ b/src/components/database/ElasticSchemas.ts @@ -168,7 +168,13 @@ export const elasticSchemas: ElasticsearchSchemas = { maxLockCounts: { type: 'text' }, oldAmount: { type: 'text' }, newAmount: { type: 'text' }, - newExpiry: { type: 'text' } + newExpiry: { type: 'text' }, + // Subsidy events (Subsidized / LockSponsored / SponsorRefunded) + Auth v2 expiry + provider: { type: 'keyword' }, + subsidyAmount: { type: 'text' }, + bonusAmount: { type: 'text' }, + expiryTimestamp: { type: 'text' }, + reclaimable: { type: 'boolean' } } } } diff --git a/src/components/database/TypesenseSchemas.ts b/src/components/database/TypesenseSchemas.ts index 6f91ddd80..66eb1cc2f 100644 --- a/src/components/database/TypesenseSchemas.ts +++ b/src/components/database/TypesenseSchemas.ts @@ -170,7 +170,13 @@ export const typesenseSchemas: TypesenseSchemas = { // ReLock event fields (uint256 kept as raw strings) { name: 'oldAmount', type: 'string', optional: true }, { name: 'newAmount', type: 'string', optional: true }, - { name: 'newExpiry', type: 'string', optional: true } + { name: 'newExpiry', type: 'string', optional: true }, + // Subsidy events (Subsidized / LockSponsored / SponsorRefunded) + Auth v2 expiry + { name: 'provider', type: 'string', optional: true }, + { name: 'subsidyAmount', type: 'string', optional: true }, + { name: 'bonusAmount', type: 'string', optional: true }, + { name: 'expiryTimestamp', type: 'string', optional: true }, + { name: 'reclaimable', type: 'bool', optional: true } ] } } diff --git a/src/components/httpRoutes/compute.ts b/src/components/httpRoutes/compute.ts index 8c6484fc1..8d0053b6d 100644 --- a/src/components/httpRoutes/compute.ts +++ b/src/components/httpRoutes/compute.ts @@ -102,7 +102,10 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/compute`, async (req, res) => { queueMaxWaitTime: req.body.queueMaxWaitTime || 0, caller: req.caller, encryptedDockerRegistryAuth: - (req.body.encryptedDockerRegistryAuth as string) || null + (req.body.encryptedDockerRegistryAuth as string) || null, + // Tri-state preserved: undefined (absent) = use node config, [] = no providers, array = use + // exactly these. Passing the raw value through keeps the absent-vs-empty distinction. + subsidyProviders: req.body.subsidyProviders as string[] | undefined } if (req.body.output) { startComputeTask.output = req.body.output @@ -423,6 +426,8 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/serviceStart`, async (req, res) => metadata: req.body.metadata ?? undefined, outputBucketId: (req.body.outputBucketId as string) || undefined, payment: req.body.payment, + // Tri-state preserved: undefined = use node config, [] = no providers, array = use these. + subsidyProviders: req.body.subsidyProviders as string[] | undefined, authorization: req.headers?.authorization, caller: req.caller } @@ -454,6 +459,8 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/serviceExtend`, async (req, res) = serviceId: (req.body.serviceId as string) || null, additionalDuration: req.body.additionalDuration as number, payment: req.body.payment, + // Tri-state preserved: undefined = use node config, [] = no providers, array = use these. + subsidyProviders: req.body.subsidyProviders as string[] | undefined, authorization: req.headers?.authorization, caller: req.caller } diff --git a/src/test/integration/algorithmsAccess.test.ts b/src/test/integration/algorithmsAccess.test.ts index 3b7e08e20..3ac936eda 100644 --- a/src/test/integration/algorithmsAccess.test.ts +++ b/src/test/integration/algorithmsAccess.test.ts @@ -412,15 +412,14 @@ describe('********** Trusted algorithms Flow', () => { .connect(consumerAccount) .deposit(initializeResponse.payment.token, balance) await depositTx.wait() - const authorizeTx = await escrowContract - .connect(consumerAccount) - .authorize( - initializeResponse.payment.token, - firstEnv.consumerAddress, - balance, - initializeResponse.payment.minLockSeconds, - 10 - ) + const authorizeTx = await escrowContract.connect(consumerAccount).authorize( + initializeResponse.payment.token, + firstEnv.consumerAddress, + balance, + initializeResponse.payment.minLockSeconds, + 10, + 0 // expiryTimestamp: 0 = indefinite (Escrow v2) + ) await authorizeTx.wait() const locks = await oceanNode.escrow.getLocks( DEVELOPMENT_CHAIN_ID, diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index e7350d45f..b16a60100 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -705,15 +705,14 @@ describe('********** Compute', () => { .connect(consumerAccount) .deposit(initializeResponse.payment.token, balance) - await escrowContract - .connect(consumerAccount) - .authorize( - initializeResponse.payment.token, - firstEnv.consumerAddress, - balance, - initializeResponse.payment.minLockSeconds, - 10 - ) + await escrowContract.connect(consumerAccount).authorize( + initializeResponse.payment.token, + firstEnv.consumerAddress, + balance, + initializeResponse.payment.minLockSeconds, + 10, + 0 // expiryTimestamp: 0 = indefinite (Escrow v2) + ) const fundsBefore = await oceanNode.escrow.getUserAvailableFunds( DEVELOPMENT_CHAIN_ID, @@ -793,6 +792,123 @@ describe('********** Compute', () => { jobWithOutputURL = jobs[0].jobId }) + // ── user-supplied subsidyProviders (PaidComputeStartHandler) ────────────── + // Mirrors the service-handler coverage in services.test.ts. These reuse the same valid + // datasets/orders/env as the successful start above; the reject cases return before createLock + // (no escrow funds consumed). + const SUBSIDY_WHITELISTED = '0xe2DD09d719Da89e5a3D0F2549c7E24566e947260' + const SUBSIDY_NON_WHITELISTED = '0x529043886F21D9bc1AE0feDb751e34265a246e47' + + // Temporarily flip the live node config to SUBSIDY_PROVIDER_FILTER=on with a whitelist, run fn, + // then restore. Handlers read config by reference via getConfig(); try/finally prevents leakage. + async function withSubsidyFilter( + whitelist: Record, + fn: () => Promise + ): Promise { + const cfg = oceanNode.getConfig() + const prevFilter = cfg.subsidyProviderFilter + const prevProviders = cfg.subsidyProviders + cfg.subsidyProviderFilter = true + cfg.subsidyProviders = whitelist + try { + await fn() + } finally { + cfg.subsidyProviderFilter = prevFilter + cfg.subsidyProviders = prevProviders + } + } + + async function buildPaidStartTask( + subsidyProviders?: string[] + ): Promise { + const nonce = Date.now().toString() + const signature = await safeSign( + consumerAccount, + createHashForSignature( + await consumerAccount.getAddress(), + nonce, + PROTOCOL_COMMANDS.COMPUTE_START + ) + ) + const re = firstEnv.resources.map((res) => ({ id: res.id, amount: res.min })) + const task: PaidComputeStartCommand = { + command: PROTOCOL_COMMANDS.COMPUTE_START, + consumerAddress: await consumerAccount.getAddress(), + signature, + nonce, + environment: firstEnv.id, + datasets: [ + { + documentId: publishedComputeDataset.ddo.id, + serviceId: publishedComputeDataset.ddo.services[0].id, + transferTxId: datasetOrderTxId + } + ], + algorithm: { + documentId: publishedAlgoDataset.ddo.id, + serviceId: publishedAlgoDataset.ddo.services[0].id, + transferTxId: algoOrderTxId, + meta: publishedAlgoDataset.ddo.metadata.algorithm + }, + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken }, + maxJobDuration: computeJobDuration, + resources: re + } + if (subsidyProviders !== undefined) task.subsidyProviders = subsidyProviders + return task + } + + it('COMPUTE_START rejects an invalid subsidyProviders address (400)', async () => { + const task = await buildPaidStartTask(['0xnot-an-address']) + const response = await new PaidComputeStartHandler(oceanNode).handle(task) + expect(response.status.httpStatus).to.equal(400) + expect(response.status.error).to.contain('subsidy provider') + }) + + it('COMPUTE_START rejects a provider outside the whitelist when the filter is on (400)', async () => { + await withSubsidyFilter( + { [String(DEVELOPMENT_CHAIN_ID)]: [SUBSIDY_WHITELISTED] }, + async () => { + const task = await buildPaidStartTask([SUBSIDY_NON_WHITELISTED]) + const response = await new PaidComputeStartHandler(oceanNode).handle(task) + expect(response.status.httpStatus).to.equal(400) + expect(response.status.error).to.contain('not allowed') + } + ) + }) + + it('COMPUTE_START persists a checksummed, whitelisted subsidyProviders list on the job payment', async function () { + // Needs escrow funds (createLock runs). Funds are still available here — the next test drains + // them. The persisted value is written synchronously by startComputeJob, so it is readable + // straight from the DB regardless of what the async batched claim does later (the whitelisted + // EOA is not a real subsidy contract, so that claim is expected to revert-and-cancel). + this.timeout(DEFAULT_TEST_TIMEOUT * 3) + await withSubsidyFilter( + { [String(DEVELOPMENT_CHAIN_ID)]: [SUBSIDY_WHITELISTED] }, + async () => { + // lower-case on input; the resolver must persist the EIP-55 checksummed form + const task = await buildPaidStartTask([SUBSIDY_WHITELISTED.toLowerCase()]) + const response = await new PaidComputeStartHandler(oceanNode).handle(task) + assert( + response.status.httpStatus === 200, + `expected 200, got ${response.status.httpStatus}: ${response.status?.error ?? ''}` + ) + const startedJobs = await streamToObject(response.stream as Readable) + const { jobId } = startedJobs[0] + assert(jobId, 'no jobId returned') + + // The public jobId is `-` (see getComputeJobStatus). The DB keys + // on the internal id, so strip the cluster-hash prefix before looking it up. + const internalJobId = jobId.substring(jobId.indexOf('-') + 1) + const [stored] = await dbconn.c2d.getJob(internalJobId) + assert(stored, 'compute job not persisted') + expect(stored.payment.subsidyProviders).to.deep.equal([ + getAddress(SUBSIDY_WHITELISTED) + ]) + } + ) + }) + it('should fail to start a compute job without escrow funds', async () => { // ensure clean escrow state: no funds, no auths, no locks const funds = await oceanNode.escrow.getUserAvailableFunds( @@ -814,7 +930,7 @@ describe('********** Compute', () => { if (auth.length > 0) { await escrowContract .connect(consumerAccount) - .authorize(initializeResponse.payment.token, firstEnv.consumerAddress, 0, 0, 0) + .authorize(initializeResponse.payment.token, firstEnv.consumerAddress, 0, 0, 0, 0) } const locks = await oceanNode.escrow.getLocks( DEVELOPMENT_CHAIN_ID, @@ -898,15 +1014,14 @@ describe('********** Compute', () => { .connect(consumerAccount) .deposit(initializeResponse.payment.token, balance) - await escrowContract - .connect(consumerAccount) - .authorize( - initializeResponse.payment.token, - firstEnv.consumerAddress, - balance, - initializeResponse.payment.minLockSeconds, - 10 - ) + await escrowContract.connect(consumerAccount).authorize( + initializeResponse.payment.token, + firstEnv.consumerAddress, + balance, + initializeResponse.payment.minLockSeconds, + 10, + 0 // expiryTimestamp: 0 = indefinite (Escrow v2) + ) const auth = await oceanNode.escrow.getAuthorizations( DEVELOPMENT_CHAIN_ID, paymentToken, @@ -1130,6 +1245,55 @@ describe('********** Compute', () => { freeJobId = jobs[0].jobId }) + it('FREE_COMPUTE_START ignores subsidyProviders (no escrow claim), even an invalid one', async () => { + // Free compute never claims escrow, so the resolver is not wired into FreeComputeStartHandler. + // A garbage subsidyProviders value must therefore be silently ignored (200), NOT rejected as it + // would be on a paid start. Locks in that "ignored" contract so a future refactor that wires the + // resolver into the shared base handler is caught. + const nonce = Date.now().toString() + const signature = await safeSign( + consumerAccount, + createHashForSignature( + await consumerAccount.getAddress(), + nonce, + PROTOCOL_COMMANDS.FREE_COMPUTE_START + ) + ) + const startComputeTask: FreeComputeStartCommand = { + command: PROTOCOL_COMMANDS.FREE_COMPUTE_START, + consumerAddress: await consumerAccount.getAddress(), + signature, + nonce, + environment: firstEnv.id, + datasets: [ + { + fileObject: computeAsset.services[0].files.files[0], + documentId: publishedComputeDataset.ddo.id, + serviceId: publishedComputeDataset.ddo.services[0].id, + transferTxId: datasetOrderTxId + } + ], + algorithm: { + fileObject: algoAsset.services[0].files.files[0], + documentId: publishedAlgoDataset.ddo.id, + serviceId: publishedAlgoDataset.ddo.services[0].id, + transferTxId: algoOrderTxId, + meta: publishedAlgoDataset.ddo.metadata.algorithm + }, + output: null, + queueMaxWaitTime: 300, + // would be a 400 on a paid start; must be ignored here + subsidyProviders: ['0xnot-an-address'] + } + const response = await new FreeComputeStartHandler(oceanNode).handle(startComputeTask) + assert( + response.status.httpStatus === 200, + `expected free compute to ignore subsidyProviders and return 200, got ${response.status.httpStatus}: ${response.status?.error ?? ''}` + ) + const startedJobs = await streamToObject(response.stream as Readable) + assert(startedJobs[0].jobId, 'expected a jobId for the free job') + }) + it('should get job status by jobId', async () => { const statusComputeTask: ComputeGetStatusCommand = { command: PROTOCOL_COMMANDS.COMPUTE_GET_STATUS, @@ -3777,7 +3941,7 @@ describe('********** Compute Access Restrictions', () => { // Remove authorization by setting to 0 await escrowContract .connect(consumerAccount) - .authorize(paymentToken, providerAddress, 0, 0, 0) + .authorize(paymentToken, providerAddress, 0, 0, 0, 0) } // Check and withdraw existing funds if any @@ -3815,7 +3979,7 @@ describe('********** Compute Access Restrictions', () => { const authorizeTx = await escrowContract .connect(consumerAccount) - .authorize(paymentToken, providerAddress, balance, 3600, 10) + .authorize(paymentToken, providerAddress, balance, 3600, 10, 0) await authorizeTx.wait() // Verify authorization is set up correctly diff --git a/src/test/integration/download.test.ts b/src/test/integration/download.test.ts index 3aed54e58..2308efbab 100644 --- a/src/test/integration/download.test.ts +++ b/src/test/integration/download.test.ts @@ -75,6 +75,8 @@ describe('********** [Download Flow] - Should run a complete node flow.' ENVIRONMENT_VARIABLES.PRIVATE_KEY, ENVIRONMENT_VARIABLES.AUTHORIZED_DECRYPTERS, ENVIRONMENT_VARIABLES.ALLOWED_ADMINS, + ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDERS, + ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDER_FILTER, ENVIRONMENT_VARIABLES.ADDRESS_FILE ], [ @@ -83,6 +85,8 @@ describe('********** [Download Flow] - Should run a complete node flow.' '0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58', JSON.stringify(['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260']), JSON.stringify(['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260']), + JSON.stringify({ '8996': ['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260'] }), + 'true', `${homedir}/.ocean/ocean-contracts/artifacts/address.json` ] ) @@ -127,6 +131,15 @@ describe('********** [Download Flow] - Should run a complete node flow.' '0xe2DD09d719Da89e5a3D0F2549c7E24566e947260'?.toLowerCase(), 'incorrect admin address' ) + assert( + status.subsidyProviders?.['8996']?.[0]?.toLowerCase() === + '0xe2DD09d719Da89e5a3D0F2549c7E24566e947260'?.toLowerCase(), + 'incorrect subsidy provider address' + ) + assert( + status.subsidyProviderFilter === true, + 'subsidyProviderFilter should reflect SUBSIDY_PROVIDER_FILTER=true' + ) assert(status.c2dClusters === undefined, 'clusters info should be undefined') assert(status.supportedSchemas === undefined, 'schemas info should be undefined') }) @@ -142,6 +155,15 @@ describe('********** [Download Flow] - Should run a complete node flow.' const status = JSON.parse(resp) assert(status.c2dClusters !== undefined, 'clusters info should not be undefined') assert(status.supportedSchemas !== undefined, 'schemas info should not be undefined') + assert( + status.subsidyProviders?.['8996']?.[0]?.toLowerCase() === + '0xe2DD09d719Da89e5a3D0F2549c7E24566e947260'?.toLowerCase(), + 'subsidy providers should be present in detailed status' + ) + assert( + status.subsidyProviderFilter === true, + 'subsidyProviderFilter should be present in detailed status' + ) }) it('should get file info before publishing', async () => { diff --git a/src/test/integration/escrow.test.ts b/src/test/integration/escrow.test.ts index 82b87801b..256a6896a 100644 --- a/src/test/integration/escrow.test.ts +++ b/src/test/integration/escrow.test.ts @@ -17,6 +17,7 @@ import { import { ENVIRONMENT_VARIABLES, EVENTS, + JobType, PROTOCOL_COMMANDS } from '../../utils/constants.js' import { @@ -183,7 +184,8 @@ describe('Indexer stores Escrow contract events', () => { payeeAddress, depositAmount, expiry, - 10 + 10, + 0 // expiryTimestamp: 0 = indefinite (Escrow v2) ) const receipt = await tx.wait() authTxHash = receipt.hash @@ -199,6 +201,8 @@ describe('Indexer stores Escrow contract events', () => { expect(event.token).to.equal(paymentToken.toLowerCase()) expect(event.maxLockedAmount).to.equal(depositAmount.toString()) expect(event.maxLockCounts).to.equal('10') + // Escrow v2: authorize was called with expiryTimestamp 0 (indefinite) + expect(event.expiryTimestamp).to.equal('0') }) it('indexes a Lock event', async function () { @@ -207,7 +211,8 @@ describe('Indexer stores Escrow contract events', () => { const tx = await escrowContract .connect(publisherAccount) - .createLock(jobId, paymentToken, payerAddress, lockAmount, expiry) + // Escrow v2: + jobType (0 = NONE) + subsidyProviders ([] = plain payer-funded lock) + .createLock(jobId, paymentToken, payerAddress, lockAmount, expiry, 0, []) const receipt = await tx.wait() lockTxHash = receipt.hash @@ -237,7 +242,8 @@ describe('Indexer stores Escrow contract events', () => { const reLockExpiry = Math.floor(expiry / 2) const tx = await escrowContract .connect(publisherAccount) - .reLock(jobId, paymentToken, payerAddress, newLockAmount, reLockExpiry) + // Escrow v2: + jobType (0 = NONE) + subsidyProviders ([] = plain payer-funded lock) + .reLock(jobId, paymentToken, payerAddress, newLockAmount, reLockExpiry, 0, []) const receipt = await tx.wait() const reLockTxHash = receipt.hash @@ -257,6 +263,89 @@ describe('Indexer stores Escrow contract events', () => { assert(event.newExpiry, 'newExpiry should be populated') }) + // Subsidy Providers claim-with-subsidy flow. This is gated on the republished + // @oceanprotocol/contracts (the new claim ABI carrying jobType + subsidyProviders) AND a + // Barge deployment that ships a MockSubsidyProvider address in the artifacts file. Until both + // are present the test self-skips, so it stays green against the current escrow while + // documenting the intended end-to-end assertion. Once the contracts are bumped, drop the + // guards below and fund/register the MockSubsidyProvider in `before()`. + it('indexes a Subsidized event on a claim carrying a subsidy provider', async function () { + if (!escrowAddress || !paymentToken) this.skip() + this.timeout(DEFAULT_TEST_TIMEOUT * 3) + + // Guard 1: the installed escrow ABI must be the new one (claimLock takes jobType + + // subsidyProviders, i.e. 7 inputs). The pre-subsidy ABI has 6. + let claimFragment: any + try { + claimFragment = escrowContract.interface.getFunction('claimLock') + } catch { + claimFragment = null + } + if (!claimFragment || claimFragment.inputs.length < 7) { + this.skip() + } + + // Guard 2: Barge must ship a deployed MockSubsidyProvider we can name as a provider. + let artifactsAddresses = getOceanArtifactsAdressesByChainId(DEVELOPMENT_CHAIN_ID) + if (!artifactsAddresses) { + artifactsAddresses = getOceanArtifactsAdresses().development + } + const subsidyProvider = + artifactsAddresses?.MockSubsidyProvider ?? artifactsAddresses?.SubsidyProvider + if (!subsidyProvider) { + this.skip() + } + + // Payee (publisher) claims the lock created for `jobId` earlier, naming the subsidy + // provider and a COMPUTE jobType. A contributing provider makes the escrow emit Subsidized. + const tx = await escrowContract + .connect(publisherAccount) + .claimLock( + jobId, + paymentToken, + payerAddress, + lockAmount, + ethers.toUtf8Bytes('subsidy-proof'), + JobType.COMPUTE, + [subsidyProvider] + ) + const receipt = await tx.wait() + const claimTxHash = receipt.hash + + const events = await waitForEscrowEvents({ + txHash: claimTxHash, + eventType: EVENTS.ESCROW_SUBSIDIZED + }) + assert(events && events.length > 0, 'Subsidized event should be indexed') + const event = events[0] + expect(event.payee).to.equal(payeeAddress.toLowerCase()) + expect(event.payer).to.equal(payerAddress.toLowerCase()) + expect(event.jobId).to.equal(jobId.toString()) + expect(event.token).to.equal(paymentToken.toLowerCase()) + expect(event.provider).to.equal(subsidyProvider.toLowerCase()) + assert(event.subsidyAmount !== undefined, 'subsidyAmount should be populated') + assert(event.bonusAmount !== undefined, 'bonusAmount should be populated') + + // And it is queryable through the getEscrowEvents command. + const response = await new EscrowEventsHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.GET_ESCROW_EVENTS, + chainId, + eventType: EVENTS.ESCROW_SUBSIDIZED, + payer: payerAddress, + caller: '127.0.0.1' + }) + expect(response.status.httpStatus).to.equal(200) + const result = JSON.parse(await streamToString(response.stream as Readable)) + assert( + result.some((e: any) => e.txHash === claimTxHash), + 'query should return the indexed Subsidized event' + ) + const queried = result.find((e: any) => e.txHash === claimTxHash) + expect(queried.provider).to.equal(subsidyProvider.toLowerCase()) + expect(queried.subsidyAmount).to.equal(event.subsidyAmount) + expect(queried.bonusAmount).to.equal(event.bonusAmount) + }) + it('returns indexed events through the EscrowEventsHandler (query command)', async function () { if (!escrowAddress || !paymentToken) this.skip() this.timeout(DEFAULT_TEST_TIMEOUT) diff --git a/src/test/integration/escrowSubsidyModes.test.ts b/src/test/integration/escrowSubsidyModes.test.ts new file mode 100644 index 000000000..c7175d588 --- /dev/null +++ b/src/test/integration/escrowSubsidyModes.test.ts @@ -0,0 +1,305 @@ +import { assert, expect } from 'chai' +import { JsonRpcProvider, Signer, ethers, parseUnits } from 'ethers' +import OceanToken from '@oceanprotocol/contracts/artifacts/contracts/utils/OceanToken.sol/OceanToken.json' with { type: 'json' } +import EscrowJson from '@oceanprotocol/contracts/artifacts/contracts/escrow/Escrow.sol/Escrow.json' with { type: 'json' } +import OPFSubsidyProvider from '@oceanprotocol/contracts/artifacts/contracts/subsidy/OPFSubsidyProvider.sol/OPFSubsidyProvider.json' with { type: 'json' } +import OneTimeSubsidyProvider from '@oceanprotocol/contracts/artifacts/contracts/subsidy/OneTimeSubsidyProvider.sol/OneTimeSubsidyProvider.json' with { type: 'json' } +import { Database } from '../../components/database/index.js' +import { OceanIndexer } from '../../components/Indexer/index.js' +import { OceanNode } from '../../OceanNode.js' +import { RPCS } from '../../@types/blockchain.js' +import { + DEVELOPMENT_CHAIN_ID, + getOceanArtifactsAdresses, + getOceanArtifactsAdressesByChainId +} from '../../utils/address.js' +import { ENVIRONMENT_VARIABLES, EVENTS, JobType } from '../../utils/constants.js' +import { + DEFAULT_TEST_TIMEOUT, + OverrideEnvConfig, + buildEnvOverrideConfig, + getMockSupportedNetworks, + setupEnvironment, + tearDownEnvironment +} from '../utils/utils.js' +import { waitForCondition } from './testUtils.js' +import { getConfiguration } from '../../utils/config.js' +import { homedir } from 'os' + +// End-to-end coverage for the Escrow v2 subsidy flows against BOTH subsidy-provider implementations +// (OPFSubsidyProvider and OneTimeSubsidyProvider), each in REFUND_ONLY and PREPAID_ONLY modes: +// - PREPAID_ONLY: the provider pre-funds the lock at createLock → escrow emits `LockSponsored`. +// - REFUND_ONLY: the provider reimburses at claim → escrow emits `Subsidized`. +// and asserts the node's indexer stores the right event with the right provider. +// +// The providers are DEPLOYED BY THE TEST (they have no constructor args and need no linking), so the +// test signer owns them and can set mode / limits / funding deterministically — Barge's default +// deploy does not ship these providers. The Escrow + Ocean token come from the local Barge address +// file; the whole suite skips when those are not deployed. +describe('Escrow v2 subsidy modes (OPF + OneTime, REFUND_ONLY + PREPAID_ONLY)', () => { + // SubsidyModeConfig enum: BOTH=0, REFUND_ONLY=1, PREPAID_ONLY=2 + const MODE_REFUND_ONLY = 1 + const MODE_PREPAID_ONLY = 2 + const jobType = JobType.COMPUTE + + let database: Database + let oceanNode: OceanNode + let indexer: OceanIndexer + let provider: JsonRpcProvider + let nodeAccount: Signer // payee — creates & claims locks + let payerAccount: Signer // payer — deposits & authorizes + let nodeAddress: string + let payerAddress: string + let paymentToken: string + let escrowAddress: string + let tokenContract: any + let escrowAsNode: any + let opfProviderAddress: string | null = null + let oneTimeProviderAddress: string | null = null + + const chainId = DEVELOPMENT_CHAIN_ID + const LOCK_AMOUNT = parseUnits('1', 18) + const LOCK_DURATION = 100000 // seconds (createLock expiry is a duration) + const FUND = parseUnits('100000', 18) + const BIG = parseUnits('1000000', 18) + let jobSeq = BigInt(Date.now()) + const nextJobId = () => (jobSeq += 1n) + + let previousConfiguration: OverrideEnvConfig[] + const mockSupportedNetworks: RPCS = getMockSupportedNetworks() + + // search() returns [] (truthy) when empty, which would resolve waitForCondition on the first poll; + // return null until a matching row is indexed. + const waitForEscrowEvents = (filters: Record) => + waitForCondition( + async () => { + const found = await database.escrow.search(filters) + return found && found.length ? found : null + }, + DEFAULT_TEST_TIMEOUT * 3 - 5000 + ) + + // Deploy a fresh subsidy provider (test signer = owner) so mode/limits/funding are fully ours. + async function deployProvider(artifact: any): Promise { + const factory = new ethers.ContractFactory( + artifact.abi, + artifact.bytecode, + nodeAccount + ) + const contract = await factory.deploy() + await contract.waitForDeployment() + return await contract.getAddress() + } + + before(async () => { + previousConfiguration = await setupEnvironment( + null, + buildEnvOverrideConfig( + [ + ENVIRONMENT_VARIABLES.RPCS, + ENVIRONMENT_VARIABLES.INDEXER_NETWORKS, + ENVIRONMENT_VARIABLES.PRIVATE_KEY, + ENVIRONMENT_VARIABLES.ADDRESS_FILE + ], + [ + JSON.stringify(mockSupportedNetworks), + JSON.stringify([DEVELOPMENT_CHAIN_ID]), + '0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58', + `${homedir}/.ocean/ocean-contracts/artifacts/address.json` + ] + ) + ) + + const config = await getConfiguration(true) + database = await Database.init(config.dbConfig) + + const oldIndexer = OceanNode.getInstance(config, database).getIndexer() + if (oldIndexer) { + await oldIndexer.stopAllChainIndexers() + } + oceanNode = OceanNode.getInstance( + config, + database, + null, + null, + null, + null, + null, + true + ) + + let artifactsAddresses = getOceanArtifactsAdressesByChainId(DEVELOPMENT_CHAIN_ID) + if (!artifactsAddresses) { + artifactsAddresses = getOceanArtifactsAdresses().development + } + escrowAddress = artifactsAddresses?.Escrow + paymentToken = artifactsAddresses?.Ocean + + provider = new JsonRpcProvider('http://127.0.0.1:8545') + nodeAccount = (await provider.getSigner(0)) as Signer + payerAccount = (await provider.getSigner(1)) as Signer + nodeAddress = await nodeAccount.getAddress() + payerAddress = await payerAccount.getAddress() + + const headBlock = await provider.getBlockNumber() + await database.indexer.update(chainId, headBlock) + + indexer = new OceanIndexer(database, config, oceanNode.blockchainRegistry) + oceanNode.addIndexer(indexer) + + if (!escrowAddress || !paymentToken) return // suite will skip + + tokenContract = new ethers.Contract(paymentToken, OceanToken.abi, nodeAccount) + escrowAsNode = new ethers.Contract(escrowAddress, EscrowJson.abi, nodeAccount) + + // Fund the payer, then deposit + authorize generously so every lock below succeeds regardless + // of how much the provider ends up sponsoring (payer covers only the unsponsored portion). + await (await tokenContract.mint(payerAddress, BIG)).wait() + await (await tokenContract.connect(payerAccount).approve(escrowAddress, BIG)).wait() + await ( + await new ethers.Contract(escrowAddress, EscrowJson.abi, payerAccount).deposit( + paymentToken, + parseUnits('1000', 18) + ) + ).wait() + await ( + await new ethers.Contract(escrowAddress, EscrowJson.abi, payerAccount).authorize( + paymentToken, + nodeAddress, + BIG, // maxLockedAmount + BIG, // maxLockSeconds (>= LOCK_DURATION) + 1000, // maxLockCounts + 0 // expiryTimestamp: indefinite + ) + ).wait() + + // Deploy both providers once (owned by nodeAccount). + opfProviderAddress = await deployProvider(OPFSubsidyProvider) + oneTimeProviderAddress = await deployProvider(OneTimeSubsidyProvider) + }) + + after(async () => { + await oceanNode.tearDownAll() + await tearDownEnvironment(previousConfiguration) + }) + + // Configure a provider for a given mode: fund it, allow the jobType + token, authorize the escrow, + // and set the subsidy mode. OPF uses setTokenLimits; OneTime uses setTokenConfig (per-user credit). + async function configureProvider( + kind: 'opf' | 'onetime', + providerAddress: string, + mode: number + ) { + const abi = kind === 'opf' ? OPFSubsidyProvider.abi : OneTimeSubsidyProvider.abi + const c = new ethers.Contract(providerAddress, abi, nodeAccount) + // Fund the provider so it has budget to sponsor / reimburse. + await (await tokenContract.mint(providerAddress, FUND)).wait() + if (kind === 'opf') { + // pctBps=10000 (100%), daily/monthly generous, weekly 0 (unused), enabled + await (await c.setTokenLimits(paymentToken, 10000, BIG, 0, BIG, true)).wait() + } else { + // pctBps=0 (no per-job cap), generous default one-time credit per user, enabled + await (await c.setTokenConfig(paymentToken, 0, BIG, true)).wait() + } + await (await c.setAllowedJobTypes([jobType])).wait() + await (await c.setAuthorizedEscrow(escrowAddress, true)).wait() + await (await c.setSubsidyMode(mode)).wait() + } + + // Drive one mode end-to-end and assert the indexed event. + async function runPrepaid(providerAddress: string) { + const jobId = nextJobId() + // createLock naming the provider → onSubsidyLock pre-funds → escrow emits LockSponsored. + const tx = await escrowAsNode.createLock( + jobId, + paymentToken, + payerAddress, + LOCK_AMOUNT, + LOCK_DURATION, + jobType, + [providerAddress] + ) + const receipt = await tx.wait() + const events = await waitForEscrowEvents({ + txHash: receipt.hash, + eventType: EVENTS.ESCROW_LOCK_SPONSORED + }) + assert(events && events.length > 0, 'LockSponsored event should be indexed') + const event = events[0] + expect(event.provider).to.equal(providerAddress.toLowerCase()) + expect(event.payer).to.equal(payerAddress.toLowerCase()) + expect(event.payee).to.equal(nodeAddress.toLowerCase()) + expect(event.jobId).to.equal(jobId.toString()) + expect(event.token).to.equal(paymentToken.toLowerCase()) + assert(event.amount !== undefined, 'sponsored amount should be populated') + expect(BigInt(event.amount) > 0n, 'sponsored amount should be > 0').to.equal(true) + } + + async function runRefund(providerAddress: string) { + const jobId = nextJobId() + // Plain payer-funded lock (no providers at lock), then claim naming the provider → + // onSubsidyClaim reimburses → escrow emits Subsidized. + await ( + await escrowAsNode.createLock( + jobId, + paymentToken, + payerAddress, + LOCK_AMOUNT, + LOCK_DURATION, + jobType, + [] + ) + ).wait() + const claimTx = await escrowAsNode.claimLock( + jobId, + paymentToken, + payerAddress, + LOCK_AMOUNT, + ethers.toUtf8Bytes('subsidy-proof'), + jobType, + [providerAddress] + ) + const receipt = await claimTx.wait() + const events = await waitForEscrowEvents({ + txHash: receipt.hash, + eventType: EVENTS.ESCROW_SUBSIDIZED + }) + assert(events && events.length > 0, 'Subsidized event should be indexed') + const event = events[0] + expect(event.provider).to.equal(providerAddress.toLowerCase()) + expect(event.payer).to.equal(payerAddress.toLowerCase()) + expect(event.payee).to.equal(nodeAddress.toLowerCase()) + expect(event.jobId).to.equal(jobId.toString()) + expect(event.token).to.equal(paymentToken.toLowerCase()) + assert(event.subsidyAmount !== undefined, 'subsidyAmount should be populated') + expect(BigInt(event.subsidyAmount) > 0n, 'subsidyAmount should be > 0').to.equal(true) + } + + it('OPFSubsidyProvider — PREPAID_ONLY → LockSponsored', async function () { + if (!escrowAddress || !paymentToken || !opfProviderAddress) this.skip() + this.timeout(DEFAULT_TEST_TIMEOUT * 4) + await configureProvider('opf', opfProviderAddress!, MODE_PREPAID_ONLY) + await runPrepaid(opfProviderAddress!) + }) + + it('OPFSubsidyProvider — REFUND_ONLY → Subsidized', async function () { + if (!escrowAddress || !paymentToken || !opfProviderAddress) this.skip() + this.timeout(DEFAULT_TEST_TIMEOUT * 4) + await configureProvider('opf', opfProviderAddress!, MODE_REFUND_ONLY) + await runRefund(opfProviderAddress!) + }) + + it('OneTimeSubsidyProvider — PREPAID_ONLY → LockSponsored', async function () { + if (!escrowAddress || !paymentToken || !oneTimeProviderAddress) this.skip() + this.timeout(DEFAULT_TEST_TIMEOUT * 4) + await configureProvider('onetime', oneTimeProviderAddress!, MODE_PREPAID_ONLY) + await runPrepaid(oneTimeProviderAddress!) + }) + + it('OneTimeSubsidyProvider — REFUND_ONLY → Subsidized', async function () { + if (!escrowAddress || !paymentToken || !oneTimeProviderAddress) this.skip() + this.timeout(DEFAULT_TEST_TIMEOUT * 4) + await configureProvider('onetime', oneTimeProviderAddress!, MODE_REFUND_ONLY) + await runRefund(oneTimeProviderAddress!) + }) +}) diff --git a/src/test/integration/services.test.ts b/src/test/integration/services.test.ts index e04eec5a7..ebfe60bdd 100644 --- a/src/test/integration/services.test.ts +++ b/src/test/integration/services.test.ts @@ -104,6 +104,13 @@ describe('********** Service on Demand', () => { let endpointUrl: string const startedServices: string[] = [] + // Subsidy-provider fixtures for the user-supplied subsidyProviders tests. WHITELISTED is what a + // filter-ON node is configured to allow; NON_WHITELISTED is a different valid address that must + // be rejected under the filter. Supplied lower-case on purpose so the resolver's EIP-55 + // normalization is observable in the persisted job. + const WHITELISTED_PROVIDER = '0xe2DD09d719Da89e5a3D0F2549c7E24566e947260' + const NON_WHITELISTED_PROVIDER = '0x529043886F21D9bc1AE0feDb751e34265a246e47' + const mockSupportedNetworks: RPCS = getMockSupportedNetworks() // ── helpers ────────────────────────────────────────────────────────── @@ -148,7 +155,7 @@ describe('********** Service on Demand', () => { await ( await escrowContract .connect(consumerAccount) - .authorize(paymentToken, beneficiaryNodeAddr, balance, minLockSeconds, 100) + .authorize(paymentToken, beneficiaryNodeAddr, balance, minLockSeconds, 100, 0) ).wait() return await oceanNode.escrow.getUserAvailableFunds( DEVELOPMENT_CHAIN_ID, @@ -220,6 +227,27 @@ describe('********** Service on Demand', () => { return engines.find((e) => e instanceof C2DEngineDocker) as C2DEngineDocker } + // Temporarily flip the live node config to SUBSIDY_PROVIDER_FILTER=on with a given per-chain + // whitelist, run `fn`, then restore. Handlers read the config by reference via getConfig(), so + // mutating it here is enough to exercise the filter without re-booting the node. try/finally keeps + // the change from leaking into the surrounding lifecycle tests. + async function withSubsidyFilter( + whitelist: Record, + fn: () => Promise + ): Promise { + const cfg = oceanNode.getConfig() + const prevFilter = cfg.subsidyProviderFilter + const prevProviders = cfg.subsidyProviders + cfg.subsidyProviderFilter = true + cfg.subsidyProviders = whitelist + try { + await fn() + } finally { + cfg.subsidyProviderFilter = prevFilter + cfg.subsidyProviders = prevProviders + } + } + // container.logs({follow: true}) never ends on its own, so read for a bounded // window and always destroy the stream afterwards to release the docker log socket. // eslint-disable-next-line require-await @@ -472,6 +500,94 @@ describe('********** Service on Demand', () => { assert(res.body.toLowerCase().includes('nginx'), 'body should be the nginx page') }) + it('(d-subsidy-1) SERVICE_START rejects an invalid subsidyProviders address (400)', async () => { + const { + consumerAddress: addr, + nonce, + signature + } = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_START) + const task: ServiceStartCommand = { + command: PROTOCOL_COMMANDS.SERVICE_START, + consumerAddress: addr, + nonce, + signature, + environment: servicesEnv.id, + image: 'nginxinc/nginx-unprivileged', + tag: 'alpine', + exposedPorts: [8080], + duration: SERVICE_DURATION, + resources: [ + { id: 'cpu', amount: 1 }, + { id: 'ram', amount: 1 } + ], + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken }, + // not a valid EVM address → rejected before any escrow interaction + subsidyProviders: ['0xnot-an-address'] + } + const resp = await new ServiceStartHandler(oceanNode).handle(task) + expect(resp.status.httpStatus).to.equal(400) + expect(resp.status.error).to.contain('subsidy provider') + }) + + it('(d-subsidy-2) SERVICE_START rejects a provider outside the whitelist when the filter is on (400)', async () => { + await withSubsidyFilter( + { [String(DEVELOPMENT_CHAIN_ID)]: [WHITELISTED_PROVIDER] }, + async () => { + const { + consumerAddress: addr, + nonce, + signature + } = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_START) + const task: ServiceStartCommand = { + command: PROTOCOL_COMMANDS.SERVICE_START, + consumerAddress: addr, + nonce, + signature, + environment: servicesEnv.id, + image: 'nginxinc/nginx-unprivileged', + tag: 'alpine', + exposedPorts: [8080], + duration: SERVICE_DURATION, + resources: [ + { id: 'cpu', amount: 1 }, + { id: 'ram', amount: 1 } + ], + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken }, + subsidyProviders: [NON_WHITELISTED_PROVIDER] + } + const resp = await new ServiceStartHandler(oceanNode).handle(task) + expect(resp.status.httpStatus).to.equal(400) + expect(resp.status.error).to.contain('not allowed') + } + ) + }) + + it('(d-subsidy-3) SERVICE_EXTEND rejects a provider outside the whitelist when the filter is on (400)', async () => { + await withSubsidyFilter( + { [String(DEVELOPMENT_CHAIN_ID)]: [WHITELISTED_PROVIDER] }, + async () => { + const { + consumerAddress: addr, + nonce, + signature + } = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_EXTEND) + const task: ServiceExtendCommand = { + command: PROTOCOL_COMMANDS.SERVICE_EXTEND, + consumerAddress: addr, + nonce, + signature, + serviceId, + additionalDuration: SERVICE_DURATION, + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken }, + subsidyProviders: [NON_WHITELISTED_PROVIDER] + } + const resp = await new ServiceExtendHandler(oceanNode).handle(task) + expect(resp.status.httpStatus).to.equal(400) + expect(resp.status.error).to.contain('not allowed') + } + ) + }) + it('(e) SERVICE_GET_STATUS returns the job with userData stripped', async () => { const job = await getServiceJob(serviceId) assert(job, 'job not found') @@ -1194,4 +1310,63 @@ describe('********** Service on Demand', () => { // stop it await getDockerEngine().stopService(job.serviceId, consumerAddress) }) + + it('(p) SERVICE_START persists a checksummed, whitelisted subsidyProviders list on the job', async function () { + // Budget matches test (d): the stopService() call below blocks on the same per-service + // lifecycle lock as the background pipeline (lock → image → claim → start), so this test does + // the full flow synchronously. NOTE: the whitelisted address here is a plain EOA, not a + // deployed subsidy-provider contract, so the on-chain claim is expected to revert and fall into + // the cancel-refund path — that is fine, this test only asserts the value persisted at request + // time (before any claim runs), so a resulting Error status is not a regression. + this.timeout(DEFAULT_TEST_TIMEOUT * 4) + await withSubsidyFilter( + { [String(DEVELOPMENT_CHAIN_ID)]: [WHITELISTED_PROVIDER] }, + async () => { + const { + consumerAddress: addr, + nonce, + signature + } = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_START) + const task: ServiceStartCommand = { + command: PROTOCOL_COMMANDS.SERVICE_START, + consumerAddress: addr, + nonce, + signature, + environment: servicesEnv.id, + image: 'nginxinc/nginx-unprivileged', + tag: 'alpine', + exposedPorts: [8080], + duration: SERVICE_DURATION, + resources: [ + { id: 'cpu', amount: 1 }, + { id: 'ram', amount: 1 } + ], + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken }, + // lower-case on input; the resolver must store the EIP-55 checksummed form + subsidyProviders: [WHITELISTED_PROVIDER.toLowerCase()] + } + const resp = await new ServiceStartHandler(oceanNode).handle(task) + assert( + resp.status.httpStatus === 200, + `expected 200, got ${resp.status.httpStatus}: ${resp.status?.error ?? ''}` + ) + const [job] = (await streamToObject(resp.stream as Readable)) as ServiceJob[] + // register for teardown before any further await, so a later assertion failure + // still leaves the service to be cleaned up by after() + startedServices.push(job.serviceId) + + // The resolved override is persisted synchronously by createServiceJob, so it is + // readable straight from the DB — no need to wait for the background pipeline. + const [stored] = await dbconn.c2d.getServiceJob(job.serviceId) + assert(stored, 'service job not persisted') + expect(stored.payment.subsidyProviders).to.deep.equal([ + ethers.getAddress(WHITELISTED_PROVIDER) + ]) + + await getDockerEngine() + .stopService(job.serviceId, consumerAddress) + .catch(() => {}) + } + ) + }) }) diff --git a/src/test/unit/compute.test.ts b/src/test/unit/compute.test.ts index fcfb4768e..668572dab 100644 --- a/src/test/unit/compute.test.ts +++ b/src/test/unit/compute.test.ts @@ -30,7 +30,7 @@ import { TEST_ENV_CONFIG_FILE } from '../utils/utils.js' import { OceanNodeConfig } from '../../@types/OceanNode.js' -import { ENVIRONMENT_VARIABLES } from '../../utils/constants.js' +import { ENVIRONMENT_VARIABLES, JobType } from '../../utils/constants.js' import { completeDBComputeJob, dockerImageManifest } from '../data/assets.js' import { C2DEngine, @@ -2503,6 +2503,9 @@ describe('service start/restart Docker cleanup on failure', function () { expect(job.status).to.equal(ServiceStatusNumber.Error) // Funds were already claimed before the container step, so no refund here. expect(engine.escrow.claimLock.calledOnce).to.equal(true) + // service-start settles as a SERVICE job so a Subsidy Provider can gate on the job type; + // jobType is the 7th positional arg to the escrow wrapper. + expect(engine.escrow.claimLock.firstCall.args[6]).to.equal(JobType.SERVICE) expect(engine.escrow.cancelExpiredLock.called).to.equal(false) }) @@ -3081,3 +3084,91 @@ describe('getDockerAdvancedConfig() PidsLimit', () => { ) }) }) + +// Compute payment settlement must tag the claim as a COMPUTE job so a Subsidy Provider can gate +// on the job type. This drives the private claimPayments() with a single claimable job and asserts +// JobType.COMPUTE reaches both escrow call sites: the batch claimLocks, and — when the batch +// throws — the per-job claimLock fallback. +describe('claimPayments passes JobType.COMPUTE to the escrow claim call sites', function () { + const CHAIN = 8996 + const TOKEN = '0xtoken' + const OWNER = '0xowner' + + function makeClaimableJob(): any { + return { + jobId: 'compute-job-1', + jobIdHash: '12345', // must match the lock's jobId (BigInt compare in claimPayments) + owner: OWNER, + environment: 'env-1', + isFree: false, + status: C2DStatusNumber.JobSettle, + maxJobDuration: 3600, + // duration = stop - start (+ build); 100s > 0 → payment is due + algoStartTimestamp: '0', + algoStopTimestamp: '100', + resources: [{ id: 'cpu', amount: 1 }], + payment: { chainId: CHAIN, token: TOKEN } + } + } + + // Build an engine whose every dependency claimPayments() touches is stubbed, so only the + // escrow claim call is exercised. `batchThrows` routes execution down the fallback path. + function buildEngine(batchThrows: boolean) { + const engine: any = Object.create(C2DEngineDocker.prototype) + const job = makeClaimableJob() + engine.envs = [{ id: 'env-1', fees: { [String(CHAIN)]: [{ feeToken: TOKEN }] } }] + engine.db = { + getJobsByStatus: sinon.stub().resolves([job]), + updateJob: sinon.stub().resolves() + } + engine.getKeyManager = sinon.stub().returns({ getEthAddress: () => '0xnode' }) + engine.getComputeEnvironment = sinon.stub().resolves({ + id: 'env-1', + minJobDuration: 60, + fees: { [String(CHAIN)]: [{ feeToken: TOKEN }] } + }) + engine.getValidBuildDurationSeconds = sinon.stub().returns(0) + engine.getTotalCostOfJob = sinon.stub().returns(5) + engine.cleanUpUnknownLocks = sinon.stub().resolves() + // A matching, non-expired lock so the job is claimed (not cancelled / marked no-lock). + const notExpired = BigInt(Math.floor(Date.now() / 1000) + 100000) + const claimLocks = batchThrows + ? sinon.stub().rejects(new Error('batch failed')) + : sinon.stub().resolves('0xbatchtx') + engine.escrow = { + getLocks: sinon + .stub() + .resolves([{ jobId: BigInt(job.jobIdHash), expiry: notExpired }]), + claimLocks, + claimLock: sinon.stub().resolves('0xsingletx'), + cancelExpiredLocks: sinon.stub().resolves('0xcancel'), + cancelExpiredLock: sinon.stub().resolves('0xcancel') + } + return { engine, job } + } + + afterEach(() => sinon.restore()) + + it('batch: claimLocks receives JobType.COMPUTE as its last positional arg', async function () { + const { engine } = buildEngine(false) + await (engine as any).claimPayments() + + expect(engine.escrow.claimLocks.calledOnce).to.equal(true) + const { args } = engine.escrow.claimLocks.firstCall + // (chainId, jobIds, tokens, payers, amounts, proofs, jobType) + expect(args[6]).to.equal(JobType.COMPUTE) + expect(engine.escrow.claimLock.called).to.equal(false) + }) + + it('fallback: per-job claimLock receives JobType.COMPUTE at arg index 6 when the batch throws', async function () { + const { engine } = buildEngine(true) + await (engine as any).claimPayments() + + // batch was attempted and rejected, so the per-job fallback ran + expect(engine.escrow.claimLocks.calledOnce).to.equal(true) + expect(engine.escrow.claimLock.calledOnce).to.equal(true) + const { args } = engine.escrow.claimLock.firstCall + // (chainId, jobId, token, payer, cost, proof, jobType) + expect(args[6]).to.equal(JobType.COMPUTE) + }) +}) diff --git a/src/test/unit/config.test.ts b/src/test/unit/config.test.ts index ce96c02fe..18f5e3649 100644 --- a/src/test/unit/config.test.ts +++ b/src/test/unit/config.test.ts @@ -1,4 +1,5 @@ import { expect } from 'chai' +import { getAddress } from 'ethers' import { OceanNodeConfig } from '../../@types/OceanNode.js' import { getConfiguration, loadConfigFromFile } from '../../utils/config.js' import { @@ -8,7 +9,7 @@ import { setupEnvironment, tearDownEnvironment } from '../utils/utils.js' -import { ENVIRONMENT_VARIABLES } from '../../utils/constants.js' +import { ENVIRONMENT_VARIABLES, JobType } from '../../utils/constants.js' import { DEFAULT_DB_INIT_MAX_ATTEMPTS, DEFAULT_DB_INIT_MAX_RETRY_DELAY, @@ -211,3 +212,177 @@ describe('Should validate P2P config from environment variables', () => { delete process.env[ENVIRONMENT_VARIABLES.P2P_MAX_CONNECTIONS.name] }) }) + +describe('JobType enum', () => { + // The numeric values are part of the on-chain claim ABI, so a silent renumbering here would + // change which subsidy path a job resolves to on-chain. Pin them. + it('has stable numeric values NONE=0, COMPUTE=1, SERVICE=2', () => { + expect(JobType.NONE).to.be.equal(0) + expect(JobType.COMPUTE).to.be.equal(1) + expect(JobType.SERVICE).to.be.equal(2) + }) +}) + +describe('Should validate SUBSIDY_PROVIDERS configuration', () => { + const DB_ENV_VARS = [ENVIRONMENT_VARIABLES.DB_TYPE, ENVIRONMENT_VARIABLES.DB_URL] + const DB_ENV_VALUES = ['typesense', 'http://localhost:8108/?apiKey=xyz'] + + // Mixed/lower-case inputs — the schema must normalize these to EIP-55 checksummed form. + const ADDR_A_LOWER = '0x1c7d4b196cb0c7b01d743fbc6116a902379c7238' + const ADDR_B_LOWER = '0x0000000000000000000000000000000000000abc' + + // Build a config with a given SUBSIDY_PROVIDERS value (or without the var when undefined), + // returning the parsed config or the thrown error. + async function configWith( + subsidyProviders?: string + ): Promise<{ config?: OceanNodeConfig; error?: Error }> { + const envVars = [...DB_ENV_VARS] + const envValues = [...DB_ENV_VALUES] + if (subsidyProviders !== undefined) { + envVars.push(ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDERS) + envValues.push(subsidyProviders) + } + const overrides = buildEnvOverrideConfig(envVars, envValues) + try { + await setupEnvironment(TEST_ENV_CONFIG_PATH, overrides) + return { config: await getConfiguration(true) } + } catch (error) { + return { error } + } finally { + await tearDownEnvironment(overrides) + } + } + + it('parses a valid per-chain map into a checksummed list', async () => { + const { config: conf, error } = await configWith( + JSON.stringify({ '8996': [ADDR_A_LOWER, ADDR_B_LOWER] }) + ) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.not.be.equal(null) + expect(conf.subsidyProviders['8996']).to.deep.equal([ + getAddress(ADDR_A_LOWER), + getAddress(ADDR_B_LOWER) + ]) + // normalized, not the raw lower-case input + expect(conf.subsidyProviders['8996'][0]).to.not.be.equal(ADDR_A_LOWER) + }) + + it('defaults to null when unset', async () => { + const { config: conf, error } = await configWith() + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.be.equal(null) + }) + + it('collapses an invalid address to null rather than throwing', async () => { + const { config: conf, error } = await configWith( + JSON.stringify({ '8996': ['0xnot-an-address'] }) + ) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.be.equal(null) + }) + + it('collapses malformed JSON to null rather than throwing', async () => { + const { config: conf, error } = await configWith('{ this is not json ]') + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.be.equal(null) + }) + + it('collapses a non-canonical chain-id key to null rather than storing it', async () => { + const { config: conf, error } = await configWith( + JSON.stringify({ '0x2105': [ADDR_A_LOWER] }) + ) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.be.equal(null) + }) + + it('applies the env var value (env overrides config.json)', async () => { + const { config: conf, error } = await configWith( + JSON.stringify({ '137': [ADDR_A_LOWER] }) + ) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders['137']).to.deep.equal([getAddress(ADDR_A_LOWER)]) + }) + + it('de-duplicates a chain list (escrow counts unique sponsors)', async () => { + const { config: conf, error } = await configWith( + JSON.stringify({ '8996': [ADDR_A_LOWER, ADDR_A_LOWER, ADDR_B_LOWER] }) + ) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders['8996']).to.deep.equal([ + getAddress(ADDR_A_LOWER), + getAddress(ADDR_B_LOWER) + ]) + }) + + it('collapses to null when a chain exceeds the per-lock unique-sponsor cap', async () => { + // 11 unique valid addresses > MAX_SUBSIDY_PROVIDERS_PER_LOCK (10) + const many = Array.from( + { length: 11 }, + (_, i) => '0x' + String(i + 1).padStart(40, '0') + ) + const { config: conf, error } = await configWith(JSON.stringify({ '8996': many })) + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviders).to.be.equal(null) + }) + + after(() => { + delete process.env.CONFIG_PATH + delete process.env.PRIVATE_KEY + delete process.env[ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDERS.name] + }) +}) + +describe('Should validate SUBSIDY_PROVIDER_FILTER configuration', () => { + const DB_ENV_VARS = [ENVIRONMENT_VARIABLES.DB_TYPE, ENVIRONMENT_VARIABLES.DB_URL] + const DB_ENV_VALUES = ['typesense', 'http://localhost:8108/?apiKey=xyz'] + + async function configWith( + filter?: string + ): Promise<{ config?: OceanNodeConfig; error?: Error }> { + const envVars = [...DB_ENV_VARS] + const envValues = [...DB_ENV_VALUES] + if (filter !== undefined) { + envVars.push(ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDER_FILTER) + envValues.push(filter) + } + const overrides = buildEnvOverrideConfig(envVars, envValues) + try { + await setupEnvironment(TEST_ENV_CONFIG_PATH, overrides) + return { config: await getConfiguration(true) } + } catch (error) { + return { error } + } finally { + await tearDownEnvironment(overrides) + } + } + + it('defaults to false when unset', async () => { + const { config: conf, error } = await configWith() + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviderFilter).to.be.equal(false) + }) + + it('parses "true" as true', async () => { + const { config: conf, error } = await configWith('true') + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviderFilter).to.be.equal(true) + }) + + it('parses "1" as true', async () => { + const { config: conf, error } = await configWith('1') + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviderFilter).to.be.equal(true) + }) + + it('parses an unrelated value as false', async () => { + const { config: conf, error } = await configWith('off') + expect(error).to.be.equal(undefined) + expect(conf.subsidyProviderFilter).to.be.equal(false) + }) + + after(() => { + delete process.env.CONFIG_PATH + delete process.env.PRIVATE_KEY + delete process.env[ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDER_FILTER.name] + }) +}) diff --git a/src/test/unit/escrowWrapper.test.ts b/src/test/unit/escrowWrapper.test.ts new file mode 100644 index 000000000..b3fd3b82d --- /dev/null +++ b/src/test/unit/escrowWrapper.test.ts @@ -0,0 +1,561 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { ethers } from 'ethers' +import { Escrow } from '../../components/core/utils/escrow.js' +import { create256Hash } from '../../utils/crypt.js' +import { JobType } from '../../utils/constants.js' + +// Direct unit test of the Escrow claim wrappers. The escrow contract, the blockchain and the +// amount/lock lookups are all stubbed, so this only exercises how the wrapper forwards the new +// jobType + subsidyProviders arguments to the contract's claim functions. +describe('Escrow claim wrappers forward jobType + subsidyProviders', () => { + const CHAIN = 8996 + const TOKEN = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' + const PAYER = '0x0000000000000000000000000000000000000aBc' + const PROVIDER = '0x1111111111111111111111111111111111111111' + const WEI = '1000' + + function buildFakeContract() { + const claimLockAndWithdraw: any = sinon.stub().resolves({ hash: '0xclaim' }) + claimLockAndWithdraw.estimateGas = sinon.stub().resolves(21000n) + const claimLocksAndWithdraw: any = sinon.stub().resolves({ hash: '0xclaims' }) + claimLocksAndWithdraw.estimateGas = sinon.stub().resolves(21000n) + const createLock: any = sinon.stub().resolves({ hash: '0xlock' }) + createLock.estimateGas = sinon.stub().resolves(21000n) + return { claimLockAndWithdraw, claimLocksAndWithdraw, createLock } + } + + // A single authorization permissive enough that createLock's pre-send checks all pass. + function okAuth() { + return { + maxLockedAmount: 10n ** 18n, + currentLockedAmount: 0n, + maxLockSeconds: 10n ** 9n, + maxLockCounts: 10n, + currentLocks: 0n + } + } + + // Stub the createLock pre-send reads (funds + auth) so the tx branch is reached. + function stubCreateLockPrechecks(escrow: Escrow) { + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon.stub(escrow, 'getAuthorizations').resolves([okAuth() as any]) + } + + // Wire up an Escrow instance whose blockchain/contract/amount/lock internals are all stubbed. + function buildEscrow(subsidyProviders: any) { + const escrow = new Escrow({} as any, 3600, {} as any, subsidyProviders) + const contract = buildFakeContract() + const fakeBlockchain = { + getSigner: sinon.stub().resolves({ getAddress: sinon.stub().resolves('0xnode') }), + getGasOptions: sinon.stub().resolves({ gasLimit: 21000n }) + } + sinon.stub(escrow as any, 'getBlockchain').returns(fakeBlockchain) + sinon.stub(escrow, 'getContract').returns(contract as any) + sinon.stub(escrow, 'getPaymentAmountInWei').resolves(WEI) + return { escrow, contract } + } + + afterEach(() => sinon.restore()) + + it('claimLock passes jobType + the chain provider list after the proof, before gas options', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + const jobId = create256Hash('job-1') + // getLocks must return a lock whose jobId matches so the claim branch runs + sinon.stub(escrow, 'getLocks').resolves([{ jobId: BigInt(jobId) } as any]) + + const hash = await escrow.claimLock( + CHAIN, + 'job-1', + TOKEN, + PAYER, + 1, + 'proof-1', + JobType.COMPUTE + ) + expect(hash).to.equal('0xclaim') + + // estimateGas: (jobId, token, payer, wei, proofBytes, jobType, subsidyProviders) + const estArgs = contract.claimLockAndWithdraw.estimateGas.firstCall.args + expect(estArgs).to.have.length(7) + expect(estArgs[5]).to.equal(JobType.COMPUTE) + expect(estArgs[6]).to.deep.equal([PROVIDER]) + + // the call itself appends gasOptions last + const callArgs = contract.claimLockAndWithdraw.firstCall.args + expect(callArgs).to.have.length(8) + expect(callArgs[0]).to.equal(jobId) + expect(callArgs[1]).to.equal(TOKEN) + expect(callArgs[2]).to.equal(PAYER) + expect(callArgs[3]).to.equal(WEI) + expect(ethers.toUtf8String(callArgs[4])).to.equal('proof-1') + expect(callArgs[5]).to.equal(JobType.COMPUTE) + expect(callArgs[6]).to.deep.equal([PROVIDER]) + }) + + it('claimLock passes an empty provider list but still the jobType when no config is set', async () => { + const { escrow, contract } = buildEscrow(null) + const jobId = create256Hash('job-2') + sinon.stub(escrow, 'getLocks').resolves([{ jobId: BigInt(jobId) } as any]) + + await escrow.claimLock(CHAIN, 'job-2', TOKEN, PAYER, 1, 'proof-2', JobType.SERVICE) + + const callArgs = contract.claimLockAndWithdraw.firstCall.args + expect(callArgs[5]).to.equal(JobType.SERVICE) + expect(callArgs[6]).to.deep.equal([]) + }) + + it('claimLocks builds parallel jobType[] and subsidyProviders[][] of matching length', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + + const jobs = ['job-a', 'job-b'] + const tokens = [TOKEN, TOKEN] + const payers = [PAYER, PAYER] + const amounts = [1, 2] + const proofs = ['pa', 'pb'] + + const hash = await escrow.claimLocks( + CHAIN, + jobs, + tokens, + payers, + amounts, + proofs, + JobType.COMPUTE + ) + expect(hash).to.equal('0xclaims') + + // estimateGas: (jobIds, tokens, payers, weis, proofs, jobTypes, subsidyProviders) + const estArgs = contract.claimLocksAndWithdraw.estimateGas.firstCall.args + expect(estArgs).to.have.length(7) + expect(estArgs[5]).to.deep.equal([JobType.COMPUTE, JobType.COMPUTE]) + expect(estArgs[6]).to.deep.equal([[PROVIDER], [PROVIDER]]) + + const callArgs = contract.claimLocksAndWithdraw.firstCall.args + expect(callArgs).to.have.length(8) + // parallel arrays are the same length as the jobs list + expect(callArgs[5]).to.have.length(jobs.length) + expect(callArgs[6]).to.have.length(jobs.length) + expect(callArgs[5]).to.deep.equal([JobType.COMPUTE, JobType.COMPUTE]) + expect(callArgs[6]).to.deep.equal([[PROVIDER], [PROVIDER]]) + }) + + it('claimLocks repeats an empty provider list per job when no config is set', async () => { + const { escrow, contract } = buildEscrow(null) + + await escrow.claimLocks( + CHAIN, + ['job-a', 'job-b'], + [TOKEN, TOKEN], + [PAYER, PAYER], + [1, 2], + ['pa', 'pb'], + JobType.COMPUTE + ) + + const callArgs = contract.claimLocksAndWithdraw.firstCall.args + expect(callArgs[5]).to.deep.equal([JobType.COMPUTE, JobType.COMPUTE]) + expect(callArgs[6]).to.deep.equal([[], []]) + }) + + const USER_PROVIDER = '0x2222222222222222222222222222222222222222' + + it('claimLock: a user override replaces the node config list', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + const jobId = create256Hash('job-ov') + sinon.stub(escrow, 'getLocks').resolves([{ jobId: BigInt(jobId) } as any]) + + await escrow.claimLock(CHAIN, 'job-ov', TOKEN, PAYER, 1, 'p', JobType.COMPUTE, [ + USER_PROVIDER + ]) + + const callArgs = contract.claimLockAndWithdraw.firstCall.args + expect(callArgs[6]).to.deep.equal([USER_PROVIDER]) + }) + + it('claimLock: a user override of [] means no providers, not fallback to config', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + const jobId = create256Hash('job-empty') + sinon.stub(escrow, 'getLocks').resolves([{ jobId: BigInt(jobId) } as any]) + + await escrow.claimLock(CHAIN, 'job-empty', TOKEN, PAYER, 1, 'p', JobType.COMPUTE, []) + + const callArgs = contract.claimLockAndWithdraw.firstCall.args + expect(callArgs[6]).to.deep.equal([]) + }) + + it('claimLock: a null override falls back to the node config list', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + const jobId = create256Hash('job-null') + sinon.stub(escrow, 'getLocks').resolves([{ jobId: BigInt(jobId) } as any]) + + await escrow.claimLock(CHAIN, 'job-null', TOKEN, PAYER, 1, 'p', JobType.COMPUTE, null) + + const callArgs = contract.claimLockAndWithdraw.firstCall.args + expect(callArgs[6]).to.deep.equal([PROVIDER]) + }) + + it('claimLocks: per-job overrides win, and a null slot falls back to config', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + + await escrow.claimLocks( + CHAIN, + ['job-a', 'job-b', 'job-c'], + [TOKEN, TOKEN, TOKEN], + [PAYER, PAYER, PAYER], + [1, 2, 3], + ['pa', 'pb', 'pc'], + JobType.COMPUTE, + [[USER_PROVIDER], [], null] + ) + + const callArgs = contract.claimLocksAndWithdraw.firstCall.args + // job-a: user override; job-b: explicit none; job-c: null → node config + expect(callArgs[6]).to.deep.equal([[USER_PROVIDER], [], [PROVIDER]]) + }) + + // Escrow v2: createLock forwards the SAME jobType + subsidyProviders the claim path uses, so a + // sponsored lock can be settled from the same providers. Resolution mirrors claimLock exactly. + it('createLock passes jobType + the chain provider list before gas options', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + stubCreateLockPrechecks(escrow) + const jobId = create256Hash('lock-1') + + const hash = await escrow.createLock( + CHAIN, + 'lock-1', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + expect(hash).to.equal('0xlock') + + // estimateGas: (jobId, token, payer, wei, expiry, jobType, subsidyProviders) + const estArgs = contract.createLock.estimateGas.firstCall.args + expect(estArgs).to.have.length(7) + expect(estArgs[5]).to.equal(JobType.COMPUTE) + expect(estArgs[6]).to.deep.equal([PROVIDER]) + + // the call itself appends gasOptions last + const callArgs = contract.createLock.firstCall.args + expect(callArgs).to.have.length(8) + expect(callArgs[0]).to.equal(jobId) + expect(callArgs[1]).to.equal(TOKEN) + expect(callArgs[2]).to.equal(PAYER) + expect(callArgs[3]).to.equal(WEI) + expect(callArgs[4]).to.equal(3600) + expect(callArgs[5]).to.equal(JobType.COMPUTE) + expect(callArgs[6]).to.deep.equal([PROVIDER]) + }) + + it('createLock passes an empty provider list but still the jobType when no config is set', async () => { + const { escrow, contract } = buildEscrow(null) + stubCreateLockPrechecks(escrow) + + await escrow.createLock(CHAIN, 'lock-2', TOKEN, PAYER, 1, 3600, JobType.SERVICE) + + const callArgs = contract.createLock.firstCall.args + expect(callArgs[5]).to.equal(JobType.SERVICE) + expect(callArgs[6]).to.deep.equal([]) + }) + + it('createLock: a user override replaces the node config list', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + stubCreateLockPrechecks(escrow) + + await escrow.createLock(CHAIN, 'lock-ov', TOKEN, PAYER, 1, 3600, JobType.COMPUTE, [ + USER_PROVIDER + ]) + + const callArgs = contract.createLock.firstCall.args + expect(callArgs[6]).to.deep.equal([USER_PROVIDER]) + }) + + it('createLock: a user override of [] means no providers, not fallback to config', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + stubCreateLockPrechecks(escrow) + + await escrow.createLock( + CHAIN, + 'lock-empty', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE, + [] + ) + + const callArgs = contract.createLock.firstCall.args + expect(callArgs[6]).to.deep.equal([]) + }) + + it('createLock: a null override falls back to the node config list', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + stubCreateLockPrechecks(escrow) + + await escrow.createLock( + CHAIN, + 'lock-null', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE, + null + ) + + const callArgs = contract.createLock.firstCall.args + expect(callArgs[6]).to.deep.equal([PROVIDER]) + }) + + // Escrow v2 stopgap: a sponsored lock skips the payer-funded pre-checks (available funds + + // maxLockedAmount cap) and lets the contract settle — so zero available funds and a + // `maxLockedAmount == 0` "sponsored-only" auth must NOT fail fast node-side. + it('createLock: a sponsored lock bypasses the funds + maxLockedAmount guards', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + // payer has nothing available and a sponsored-only auth (maxLockedAmount == 0) + sinon.stub(escrow, 'getUserAvailableFunds').resolves(0n as any) + sinon.stub(escrow, 'getAuthorizations').resolves([ + { + maxLockedAmount: 0n, + currentLockedAmount: 0n, + maxLockSeconds: 10n ** 9n, + maxLockCounts: 10n, + currentLocks: 0n + } as any + ]) + + // no override → resolves to the node config list → sponsored + const hash = await escrow.createLock( + CHAIN, + 'lock-spon', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + expect(hash).to.equal('0xlock') + expect(contract.createLock.firstCall.args[6]).to.deep.equal([PROVIDER]) + }) + + it('createLock: a plain payer-funded lock still enforces the funds guard', async () => { + const { escrow, contract } = buildEscrow(null) // no providers → not sponsored + sinon.stub(escrow, 'getUserAvailableFunds').resolves(0n as any) // < wei (1000) + + let err: any + try { + await escrow.createLock(CHAIN, 'lock-poor', TOKEN, PAYER, 1, 3600, JobType.COMPUTE) + } catch (e) { + err = e + } + expect(err, 'should reject when the payer lacks funds').to.be.an('error') + expect(err.message).to.contain('does not have enough funds') + expect(contract.createLock.called).to.equal(false) + }) + + // Escrow v2 auth expiry: the node checks the auth's expiryTimestamp before locking and fails + // fast, instead of sending a tx the contract reverts with "Auth expired". + function authWithExpiry(expiryTimestamp: bigint) { + return { ...okAuth(), expiryTimestamp } + } + const nowSec = () => Math.floor(Date.now() / 1000) + + it('createLock: rejects when the authorization has already expired', async () => { + const { escrow, contract } = buildEscrow(null) + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon + .stub(escrow, 'getAuthorizations') + .resolves([authWithExpiry(BigInt(nowSec() - 100)) as any]) + + let err: any + try { + await escrow.createLock(CHAIN, 'lock-exp', TOKEN, PAYER, 1, 3600, JobType.COMPUTE) + } catch (e) { + err = e + } + expect(err).to.be.an('error') + expect(err.message).to.contain('authorization expired') + expect(contract.createLock.called).to.equal(false) + }) + + it('createLock: rejects when the lock would outlive the authorization expiry', async () => { + const { escrow, contract } = buildEscrow(null) + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + // auth lapses in 100s but the lock lasts 3600s → lock would outlive the auth + sinon + .stub(escrow, 'getAuthorizations') + .resolves([authWithExpiry(BigInt(nowSec() + 100)) as any]) + + let err: any + try { + await escrow.createLock( + CHAIN, + 'lock-outlive', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + } catch (e) { + err = e + } + expect(err).to.be.an('error') + expect(err.message).to.contain('outlive') + expect(contract.createLock.called).to.equal(false) + }) + + it('createLock: a far-future auth expiry (and expiry 0) still lock', async () => { + // far-future expiryTimestamp: lock proceeds + const far = buildEscrow(null) + sinon.stub(far.escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon + .stub(far.escrow, 'getAuthorizations') + .resolves([authWithExpiry(BigInt(nowSec() + 10 ** 9)) as any]) + expect( + await far.escrow.createLock( + CHAIN, + 'lock-far', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + ).to.equal('0xlock') + + // expiryTimestamp == 0 (indefinite) is a no-op → lock proceeds + const zero = buildEscrow(null) + sinon.stub(zero.escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon.stub(zero.escrow, 'getAuthorizations').resolves([authWithExpiry(0n) as any]) + expect( + await zero.escrow.createLock( + CHAIN, + 'lock-zero', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + ).to.equal('0xlock') + }) + + // Boundary: the guard mirrors the contract's `block.timestamp + duration <= expiry`, so a lock + // ending EXACTLY at expiry is allowed, and one second past it is rejected. Time is frozen so the + // boundary is deterministic (createLock reads Date.now()). + it('createLock: a lock ending exactly at the auth expiry is allowed', async () => { + const nowMs = 1_700_000_000_000 + sinon.useFakeTimers(nowMs) + const duration = 3600 + const expiryTs = BigInt(Math.floor(nowMs / 1000) + duration) // lock end == expiry + const { escrow } = buildEscrow(null) + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon.stub(escrow, 'getAuthorizations').resolves([authWithExpiry(expiryTs) as any]) + + expect( + await escrow.createLock( + CHAIN, + 'lock-at-expiry', + TOKEN, + PAYER, + 1, + duration, + JobType.COMPUTE + ) + ).to.equal('0xlock') + }) + + it('createLock: a lock ending one second past the auth expiry is rejected', async () => { + const nowMs = 1_700_000_000_000 + sinon.useFakeTimers(nowMs) + const duration = 3600 + const expiryTs = BigInt(Math.floor(nowMs / 1000) + duration - 1) // lock end > expiry by 1s + const { escrow, contract } = buildEscrow(null) + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon.stub(escrow, 'getAuthorizations').resolves([authWithExpiry(expiryTs) as any]) + + let err: any + try { + await escrow.createLock( + CHAIN, + 'lock-past-expiry', + TOKEN, + PAYER, + 1, + duration, + JobType.COMPUTE + ) + } catch (e) { + err = e + } + expect(err).to.be.an('error') + expect(err.message).to.contain('outlive') + expect(contract.createLock.called).to.equal(false) + }) + + // Expiry is enforced regardless of sponsorship (the contract gates createLock on expiry for + // sponsored and payer-funded locks alike). + it('createLock: an expired auth rejects even for a sponsored lock', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + sinon.stub(escrow, 'getUserAvailableFunds').resolves(0n as any) + sinon + .stub(escrow, 'getAuthorizations') + .resolves([authWithExpiry(BigInt(nowSec() - 100)) as any]) + + let err: any + try { + await escrow.createLock( + CHAIN, + 'lock-spon-exp', + TOKEN, + PAYER, + 1, + 3600, + JobType.COMPUTE + ) + } catch (e) { + err = e + } + expect(err).to.be.an('error') + expect(err.message).to.contain('authorization expired') + expect(contract.createLock.called).to.equal(false) + }) + + // Stopgap S=0: a node with a global provider list skips the funds guard even when the payer has + // nothing and the providers end up covering 0. The node must NOT fast-fail — it reaches the + // contract, which reverts, and the wrapper surfaces that revert. (When the TODO to quote S is + // implemented, this flips to expecting a node-side fast-fail.) + it('createLock: sponsored but underfunded payer surfaces the contract revert (stopgap S=0)', async () => { + const { escrow, contract } = buildEscrow({ [String(CHAIN)]: [PROVIDER] }) + sinon.stub(escrow, 'getUserAvailableFunds').resolves(0n as any) + sinon.stub(escrow, 'getAuthorizations').resolves([okAuth() as any]) + contract.createLock.estimateGas.rejects(new Error('Payer does not have enough funds')) + + let err: any + try { + await escrow.createLock(CHAIN, 'lock-s0', TOKEN, PAYER, 1, 3600, JobType.COMPUTE) + } catch (e) { + err = e + } + expect(err).to.be.an('error') + expect(err.message).to.contain('Payer does not have enough funds') + // proves the node did NOT fast-fail on the skipped funds guard: it reached the contract + expect(contract.createLock.estimateGas.called).to.equal(true) + }) + + // Pre-v2 safety: an auth tuple without an `expiryTimestamp` field (old ABI) is a no-op for the + // expiry gate and must still lock. + it('createLock: an auth with no expiryTimestamp (pre-v2) still locks', async () => { + const { escrow } = buildEscrow(null) + sinon.stub(escrow, 'getUserAvailableFunds').resolves((10n ** 18n) as any) + sinon.stub(escrow, 'getAuthorizations').resolves([okAuth() as any]) // no expiryTimestamp + expect( + await escrow.createLock(CHAIN, 'lock-prev2', TOKEN, PAYER, 1, 3600, JobType.COMPUTE) + ).to.equal('0xlock') + }) +}) diff --git a/src/test/unit/indexerEventMap.test.ts b/src/test/unit/indexerEventMap.test.ts new file mode 100644 index 000000000..08c8f9d19 --- /dev/null +++ b/src/test/unit/indexerEventMap.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai' +import { ethers } from 'ethers' +import { EVENT_PROCESSOR_MAP } from '../../components/Indexer/processor.js' +import { EscrowEventProcessor } from '../../components/Indexer/processors/EscrowEventProcessor.js' +import { ESCROW_EVENTS, EVENTS, EVENT_HASHES } from '../../utils/constants.js' + +// Regression guard: the indexer dispatches a decoded log by its event-type string through +// EVENT_PROCESSOR_MAP, and getEventProcessor throws "No processor found for event type: X" when a +// type is missing. It is easy to add an event to the decoder / ESCROW_EVENTS / topic0 map but +// forget this dispatch map (exactly what happened for LockSponsored / SponsorRefunded), which makes +// the new decode branches dead code and can break indexing of the block that emits them. +describe('Indexer EVENT_PROCESSOR_MAP wiring', () => { + it('every ESCROW_EVENTS type resolves to a processor', () => { + for (const eventType of ESCROW_EVENTS) { + expect( + EVENT_PROCESSOR_MAP[eventType], + `missing EVENT_PROCESSOR_MAP entry for escrow event "${eventType}"` + ).to.not.equal(undefined) + } + }) + + it('maps the Escrow v2 lock-time sponsorship events to the EscrowEventProcessor', () => { + expect(EVENT_PROCESSOR_MAP[EVENTS.ESCROW_LOCK_SPONSORED]).to.equal( + EscrowEventProcessor + ) + expect(EVENT_PROCESSOR_MAP[EVENTS.ESCROW_SPONSOR_REFUNDED]).to.equal( + EscrowEventProcessor + ) + }) + + // Guard against a hand-edited topic0 key or signature text drifting apart: for every escrow + // entry in EVENT_HASHES, keccak256(signature text) must equal the map key. This is what the + // indexer matches logs against, so a typo silently stops indexing that event. + it('each escrow EVENT_HASHES key is keccak256 of its signature text', () => { + const escrowTypes = new Set(ESCROW_EVENTS) + const checked: string[] = [] + for (const [topic0, entry] of Object.entries(EVENT_HASHES)) { + if (!escrowTypes.has(entry.type)) continue + expect( + ethers.id(entry.text), + `topic0 mismatch for ${entry.type} (${entry.text})` + ).to.equal(topic0) + checked.push(entry.type) + } + // all escrow event types must be present in the topic0 map + for (const t of ESCROW_EVENTS) { + expect(checked, `missing EVENT_HASHES entry for escrow event "${t}"`).to.include(t) + } + }) +}) diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index fe18f2efd..826b1c664 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -2,7 +2,7 @@ import { assert, expect } from 'chai' import { Readable } from 'stream' import sinon from 'sinon' import { streamToObject } from '../../../utils/util.js' -import { PROTOCOL_COMMANDS } from '../../../utils/constants.js' +import { PROTOCOL_COMMANDS, JobType } from '../../../utils/constants.js' import { ServiceStatusNumber, ServiceJob } from '../../../@types/C2D/ServiceOnDemand.js' import { ServiceGetTemplatesHandler } from '../../../components/core/service/getTemplates.js' import { ServiceGetStatusHandler } from '../../../components/core/service/getStatus.js' @@ -702,6 +702,14 @@ describe('Service handlers', () => { expect(res.status.httpStatus).to.equal(200) expect(escrow.createLock.calledOnce).to.equal(true) expect(escrow.claimLock.calledOnce).to.equal(true) + // service-extend settles as a SERVICE job; jobType is the 7th positional arg + expect(escrow.claimLock.firstCall.args[6]).to.equal(JobType.SERVICE) + // createLock must carry the same jobType and the SAME subsidy-provider list as the claim + // (lock & claim must agree so a sponsored lock settles from the same providers). + expect(escrow.createLock.firstCall.args[6]).to.equal(JobType.SERVICE) + expect(escrow.createLock.firstCall.args[7]).to.deep.equal( + escrow.claimLock.firstCall.args[7] + ) // two writes: the durable intent (before claim) + the finalized extension expect(engine.db.updateServiceJob.calledTwice).to.equal(true) const out = await body(res) @@ -1009,6 +1017,19 @@ describe('Service handlers', () => { expect(engine.createServiceJob.called).to.equal(false) }) + it('skips the escrow funds pre-check for a sponsored request (zero-deposit SoD)', async () => { + const { node, engine } = buildFakes() + engine.escrow.getUserAvailableFunds.resolves(0n) // payer has nothing available… + // …but the request names a subsidy provider → sponsored → the funds pre-check is skipped, + // so the start is NOT refused for insufficient funds (parity with the compute createLock + // stopgap; the contract settles the real payer portion authoritatively). + const res = await new ServiceStartHandler(node).handle({ + ...baseTask, + subsidyProviders: ['0x1111111111111111111111111111111111111111'] + } as any) + expect(String(res.status.error || '')).to.not.contain('Insufficient escrow funds') + }) + it('the funds pre-check is best-effort: an RPC failure does not block the start', async () => { const { node, engine } = buildFakes() engine.escrow.getUserAvailableFunds.rejects(new Error('rpc down')) diff --git a/src/test/unit/subsidyProviders.test.ts b/src/test/unit/subsidyProviders.test.ts new file mode 100644 index 000000000..d47c51f34 --- /dev/null +++ b/src/test/unit/subsidyProviders.test.ts @@ -0,0 +1,120 @@ +import { expect } from 'chai' +import { getAddress } from 'ethers' +import { resolveUserSubsidyProviders } from '../../components/core/utils/subsidyProviders.js' +import { OceanNodeConfig } from '../../@types/OceanNode.js' + +// Pure-logic unit test of the user-supplied subsidy-provider resolver: the tri-state semantics +// (undefined/empty/array), address validation + checksum normalization, and the whitelist filter. +describe('resolveUserSubsidyProviders', () => { + const CHAIN = 8996 + // lowercase on purpose, to prove the resolver + whitelist comparison are checksum-insensitive + const A = '0x1111111111111111111111111111111111111111' + const B = '0x2222222222222222222222222222222222222222' + const C = '0x3333333333333333333333333333333333333333' + + function cfg(partial: Partial): OceanNodeConfig { + return partial as OceanNodeConfig + } + + it('undefined → use node config (resolved undefined)', () => { + const r = resolveUserSubsidyProviders(undefined, CHAIN, cfg({})) + expect(r.valid).to.equal(true) + expect(r.resolved).to.equal(undefined) + }) + + it('null → use node config (treated like undefined)', () => { + const r = resolveUserSubsidyProviders(null as any, CHAIN, cfg({})) + expect(r.valid).to.equal(true) + expect(r.resolved).to.equal(undefined) + }) + + it('empty array → explicit no-providers (resolved [])', () => { + const r = resolveUserSubsidyProviders([], CHAIN, cfg({})) + expect(r.valid).to.equal(true) + expect(r.resolved).to.deep.equal([]) + }) + + it('collapses duplicate addresses to a single unique sponsor, checksummed', () => { + const r = resolveUserSubsidyProviders([A, A], CHAIN, cfg({})) + expect(r.valid).to.equal(true) + // the escrow counts UNIQUE sponsors, so duplicates are collapsed + expect(r.resolved).to.deep.equal([getAddress(A)]) + }) + + it('valid array → checksummed, filter off', () => { + const r = resolveUserSubsidyProviders([A, B], CHAIN, cfg({})) + expect(r.valid).to.equal(true) + expect(r.resolved).to.deep.equal([getAddress(A), getAddress(B)]) + }) + + // The escrow caps unique sponsors per lock at MAX_SUBSIDY_PROVIDERS_PER_LOCK (10). The resolver + // enforces that locally (unique count) so an over-long list is a cheap 400, not an estimateGas + // round-trip on a guaranteed "Too many sponsors" revert. + const mkAddrs = (n: number) => + Array.from({ length: n }, (_, i) => '0x' + String(i + 1).padStart(40, '0')) + + it('rejects more than the per-lock unique-sponsor cap', () => { + const r = resolveUserSubsidyProviders(mkAddrs(11), CHAIN, cfg({})) + expect(r.valid).to.equal(false) + expect(r.reason).to.contain('Too many subsidy providers') + }) + + it('allows exactly the cap, and the cap counts UNIQUE sponsors (dupes collapse under it)', () => { + const ten = mkAddrs(10) + const r = resolveUserSubsidyProviders(ten, CHAIN, cfg({})) + expect(r.valid).to.equal(true) + expect(r.resolved).to.have.length(10) + // 12 entries but only 10 unique → valid after dedup (cap is on unique count, not raw length) + const r2 = resolveUserSubsidyProviders([...ten, ten[0], ten[1]], CHAIN, cfg({})) + expect(r2.valid).to.equal(true) + expect(r2.resolved).to.have.length(10) + }) + + it('rejects an invalid address', () => { + const r = resolveUserSubsidyProviders([A, 'not-an-address'], CHAIN, cfg({})) + expect(r.valid).to.equal(false) + expect(r.reason).to.contain('not-an-address') + expect(r.resolved).to.equal(undefined) + }) + + it('rejects a non-array value', () => { + const r = resolveUserSubsidyProviders('nope' as any, CHAIN, cfg({})) + expect(r.valid).to.equal(false) + }) + + describe('SUBSIDY_PROVIDER_FILTER on', () => { + const config = cfg({ + subsidyProviderFilter: true, + subsidyProviders: { [String(CHAIN)]: [A, B] } + }) + + it('allows a subset of the whitelist', () => { + const r = resolveUserSubsidyProviders([A], CHAIN, config) + expect(r.valid).to.equal(true) + expect(r.resolved).to.deep.equal([getAddress(A)]) + }) + + it('allows an empty list even when the whitelist is non-empty', () => { + const r = resolveUserSubsidyProviders([], CHAIN, config) + expect(r.valid).to.equal(true) + expect(r.resolved).to.deep.equal([]) + }) + + it('rejects an address outside the whitelist', () => { + const r = resolveUserSubsidyProviders([A, C], CHAIN, config) + expect(r.valid).to.equal(false) + expect(r.reason).to.contain(getAddress(C)) + }) + + it('rejects any list on a chain with no whitelist entry', () => { + const r = resolveUserSubsidyProviders([A], 1, config) + expect(r.valid).to.equal(false) + }) + + it('undefined still passes through (node config used at claim time)', () => { + const r = resolveUserSubsidyProviders(undefined, CHAIN, config) + expect(r.valid).to.equal(true) + expect(r.resolved).to.equal(undefined) + }) + }) +}) diff --git a/src/utils/config/builder.ts b/src/utils/config/builder.ts index 8764ac506..1b1af8a2c 100644 --- a/src/utils/config/builder.ts +++ b/src/utils/config/builder.ts @@ -300,6 +300,21 @@ export async function getConfiguration( previousConfiguration.codeHash = await computeCodebaseHash(__dirname) } + if (isStartup) { + // Insecure-default warning: with SUBSIDY_PROVIDER_FILTER off, an untrusted consumer may name + // ANY sponsor address in a request and have this node lock/claim against it (an open, + // sybil-drainable sponsorship program). Warn loudly when the operator has configured + // SUBSIDY_PROVIDERS but left the filter off, so the exposure is a deliberate choice. + const sp = previousConfiguration.subsidyProviders + const hasSubsidyProviders = + !!sp && Object.values(sp).some((list) => Array.isArray(list) && list.length > 0) + if (hasSubsidyProviders && !previousConfiguration.subsidyProviderFilter) { + CONFIG_LOGGER.warn( + 'SUBSIDY_PROVIDERS is configured but SUBSIDY_PROVIDER_FILTER is OFF: any consumer may name ANY sponsor address through this node (open, sybil-drainable sponsorship). Set SUBSIDY_PROVIDER_FILTER=true to restrict callers to this node’s configured providers.' + ) + } + } + return previousConfiguration } diff --git a/src/utils/config/constants.ts b/src/utils/config/constants.ts index 924a6526a..4257c5ec8 100644 --- a/src/utils/config/constants.ts +++ b/src/utils/config/constants.ts @@ -34,6 +34,8 @@ export const ENV_TO_CONFIG_MAPPING = { AUTHORIZED_PUBLISHERS_LIST: 'authorizedPublishersList', ALLOWED_ADMINS: 'allowedAdmins', ALLOWED_ADMINS_LIST: 'allowedAdminsList', + SUBSIDY_PROVIDERS: 'subsidyProviders', + SUBSIDY_PROVIDER_FILTER: 'subsidyProviderFilter', DOCKER_COMPUTE_ENVIRONMENTS: 'dockerComputeEnvironments', SERVICE_TEMPLATES_PATH: 'serviceTemplatesPath', DOCKER_REGISTRY_AUTHS: 'dockerRegistrysAuth', diff --git a/src/utils/config/schemas.ts b/src/utils/config/schemas.ts index ae64b6910..c58967ec1 100644 --- a/src/utils/config/schemas.ts +++ b/src/utils/config/schemas.ts @@ -24,6 +24,7 @@ import { SENDTO_MAX_CONCURRENCY_CAP, normalizeP2pBudget } from '../../components/P2P/timeouts.js' +import { MAX_SUBSIDY_PROVIDERS_PER_LOCK } from '../../components/core/utils/subsidyProviders.js' function isValidUrl(urlString: string): boolean { try { @@ -99,6 +100,56 @@ export const AccessListContractSchema = z.preprocess( z.record(z.string(), z.array(z.string())).nullable() ) +// Per-chain map of Subsidy Provider contract addresses `{ "": ["0x.."] }`, passed to +// the escrow at lock and claim time. Same shape as AccessListContract, but each chain's addresses +// are normalized to their EIP-55 checksummed form via ethers `getAddress` and de-duplicated. +// Anything malformed (bad JSON, not a per-chain object, an invalid address, or more than +// `MAX_SUBSIDY_PROVIDERS_PER_LOCK` unique providers on a chain) collapses the whole map to `null` +// rather than throwing, so a typo in this optional knob can never keep the node from booting. +export const SubsidyProvidersSchema = z.preprocess( + (val) => { + if (val === null || val === undefined) return null + if (typeof val === 'string') { + try { + val = JSON.parse(val) + } catch { + return null + } + } + if (typeof val !== 'object' || Array.isArray(val)) return null + try { + const checksummed: Record = {} + for (const [chainId, addresses] of Object.entries(val as Record)) { + // Keys must be canonical decimal chain ids (matching `String(chainId)` used by + // Escrow.getSubsidyProvidersForChain); reject hex/whitespace/leading-zero/non-numeric + // keys that would otherwise be stored but never matched at claim time. + if (!/^[1-9]\d*$/.test(chainId)) return null + if (!Array.isArray(addresses)) return null + // Checksum + de-duplicate: the escrow counts UNIQUE sponsors, so duplicates are one + // sponsor. This is also the default list handed to createLock/claimLock when a request + // supplies no override, so it must obey the same per-lock cap the request path enforces. + const unique = Array.from( + new Set(addresses.map((addr) => getAddress(addr as string))) + ) + if (unique.length > MAX_SUBSIDY_PROVIDERS_PER_LOCK) { + // A configured list above the escrow's `maxSponsorsPerLock()` would make every default + // lock/claim for this chain revert "Too many sponsors"; treat it as invalid config. + CONFIG_LOGGER.error( + `SUBSIDY_PROVIDERS for chain ${chainId} has ${unique.length} unique providers; max is ${MAX_SUBSIDY_PROVIDERS_PER_LOCK} per lock` + ) + return null + } + checksummed[chainId] = unique + } + return checksummed + } catch (error) { + CONFIG_LOGGER.error(`Invalid address in SUBSIDY_PROVIDERS: ${error.message}`) + return null + } + }, + z.record(z.string(), z.array(z.string())).nullable() +) + export const OceanNodeConfigKeysSchema = z.object({ privateKey: z.any().optional().nullable(), type: z.string().optional().default('raw') @@ -1139,6 +1190,11 @@ export const OceanNodeConfigSchema = z }), allowedAdmins: addressArrayFromString.optional(), allowedAdminsList: jsonFromString(AccessListContractSchema).optional(), + subsidyProviders: SubsidyProvidersSchema.optional().default(null), + // When ON, a user-supplied subsidy-provider list may only contain addresses that are + // already in this node's `subsidyProviders` whitelist for the request's chain; anything + // else is rejected. Default OFF (users may send any valid address). + subsidyProviderFilter: booleanFromString.optional().default(false), codeHash: z.string().optional(), maxConnections: z.coerce.number().optional(), diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f62ed9ec4..a1db8a4cf 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -140,6 +140,18 @@ export const MetadataStates = { UNLISTED: 5 } +// jobType passed to the Escrow claim functions so a Subsidy Provider can gate on the kind of +// job being settled. The numeric values are part of the on-chain claim ABI — the escrow and any +// Subsidy Provider contract read them directly, so they MUST NOT be renumbered. Future features +// add their own id. +/* eslint-disable no-unused-vars */ +export enum JobType { + NONE = 0, + COMPUTE = 1, // C2D compute jobs + SERVICE = 2 // service-on-demand +} +/* eslint-enable no-unused-vars */ + export const EVENTS = { METADATA_CREATED: 'MetadataCreated', METADATA_UPDATED: 'MetadataUpdated', @@ -164,7 +176,11 @@ export const EVENTS = { ESCROW_CLAIMED: 'Claimed', ESCROW_CANCELED: 'Canceled', ESCROW_DEPOSIT: 'Deposit', - ESCROW_WITHDRAW: 'Withdraw' + ESCROW_WITHDRAW: 'Withdraw', + ESCROW_SUBSIDIZED: 'Subsidized', + // Escrow v2 lock-time ("prepaid") sponsorship events. + ESCROW_LOCK_SPONSORED: 'LockSponsored', + ESCROW_SPONSOR_REFUNDED: 'SponsorRefunded' } export const ESCROW_EVENTS = [ @@ -174,7 +190,10 @@ export const ESCROW_EVENTS = [ EVENTS.ESCROW_CLAIMED, EVENTS.ESCROW_CANCELED, EVENTS.ESCROW_DEPOSIT, - EVENTS.ESCROW_WITHDRAW + EVENTS.ESCROW_WITHDRAW, + EVENTS.ESCROW_SUBSIDIZED, + EVENTS.ESCROW_LOCK_SPONSORED, + EVENTS.ESCROW_SPONSOR_REFUNDED ] export const INDEXER_CRAWLING_EVENTS = { @@ -257,9 +276,9 @@ export const EVENT_HASHES: Hashes = { type: EVENTS.NEW_ACCESS_LIST, text: 'NewAccessList(address,address)' }, - '0x5a3021f46552b1ac3c96e967ff1ecfeb100603ccc2940941cad97db3ee2baec7': { + '0x47f2ef7a6a140df663e93e9dd0adaec413cb4dbe13624c53a5247c47eda35a45': { type: EVENTS.ESCROW_AUTH, - text: 'Auth(address,address,address,uint256,uint256,uint256)' + text: 'Auth(address,address,address,uint256,uint256,uint256,uint256)' }, '0xb746b0421b0b98debe76bb312ec9fb701603af22ddb107f7e639b0187e4ff880': { type: EVENTS.ESCROW_LOCK, @@ -284,6 +303,18 @@ export const EVENT_HASHES: Hashes = { '0x9b1bfa7fa9ee420a16e124f794c35ac9f90472acc99140eb2f6447c714cad8eb': { type: EVENTS.ESCROW_WITHDRAW, text: 'Withdraw(address,address,uint256)' + }, + '0x04e202f6138ce0268067aab74c4038deaca9e8f15460867eff076939f0b06336': { + type: EVENTS.ESCROW_SUBSIDIZED, + text: 'Subsidized(address,address,uint256,address,address,uint256,uint256)' + }, + '0xe7e10d1edd43df8fcf505863fbef6140798ab67b9d2a9bf0a84a1aa11c0d3825': { + type: EVENTS.ESCROW_LOCK_SPONSORED, + text: 'LockSponsored(address,address,uint256,address,address,uint256)' + }, + '0x481f34587ff8179417e483b191c864eb16743e5d059e88b3f4e67f65ca23ab74': { + type: EVENTS.ESCROW_SPONSOR_REFUNDED, + text: 'SponsorRefunded(address,address,uint256,address,address,uint256,bool)' } } @@ -421,6 +452,19 @@ export const ENVIRONMENT_VARIABLES: Record = { value: process.env.ALLOWED_ADMINS_LIST, required: false }, + SUBSIDY_PROVIDERS: { + // per-chain map of Subsidy Provider contract addresses passed to the escrow at claim time + name: 'SUBSIDY_PROVIDERS', + value: process.env.SUBSIDY_PROVIDERS, + required: false + }, + SUBSIDY_PROVIDER_FILTER: { + // when ON, a user-supplied subsidy-provider list may only contain addresses already present + // in this node's SUBSIDY_PROVIDERS whitelist for the request chain; anything else is rejected + name: 'SUBSIDY_PROVIDER_FILTER', + value: process.env.SUBSIDY_PROVIDER_FILTER, + required: false + }, ASSET_PURGATORY_URL: { name: 'ASSET_PURGATORY_URL', value: process.env.ASSET_PURGATORY_URL,