From c55158afad406d062623889607509dc30fdf3a5d Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 24 Sep 2026 10:58:34 +0300 Subject: [PATCH 1/2] escrow subsidy providers --- docs/API.md | 15 +- docs/env.md | 1 + package-lock.json | 9 +- package.json | 2 +- src/@types/Escrow.ts | 4 + src/@types/OceanNode.ts | 4 + src/OceanNode.ts | 6 +- src/components/Indexer/processor.ts | 3 +- .../processors/EscrowEventProcessor.ts | 10 ++ src/components/c2d/compute_engine_docker.ts | 11 +- src/components/core/service/extendService.ts | 4 +- src/components/core/utils/escrow.ts | 41 ++++- src/components/core/utils/statusHandler.ts | 3 + src/test/integration/download.test.ts | 12 ++ src/test/integration/escrow.test.ts | 80 ++++++++++ src/test/unit/compute.test.ts | 93 +++++++++++- src/test/unit/config.test.ts | 92 +++++++++++- src/test/unit/escrowWrapper.test.ts | 141 ++++++++++++++++++ src/test/unit/service/serviceHandlers.test.ts | 4 +- src/utils/config/constants.ts | 1 + src/utils/config/schemas.ts | 32 ++++ src/utils/constants.ts | 28 +++- 22 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 src/test/unit/escrowWrapper.test.ts diff --git a/docs/API.md b/docs/API.md index 6be6f7ad8..e5e12ac33 100644 --- a/docs/API.md +++ b/docs/API.md @@ -872,6 +872,12 @@ returns status of node "arwave": true "url": true }, + "escrowAddress": { + "8996": "0x123" + }, + "subsidyProviders": { + "8996": ["0x123", "0x456"] + }, "uptime": 123, "platform": { "cpus": "123", @@ -889,6 +895,11 @@ 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 claim time; it is `{}` when none are configured. Both are present in +the normal and detailed status. + --- ## Query DDO @@ -1423,7 +1434,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` | | payer | string | | payer address (case-insensitive) | | payee | string | | payee address (case-insensitive) | | token | string | | token address (case-insensitive) | @@ -1446,7 +1457,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`). A `Subsidized` row (emitted once per contributing Subsidy Provider at claim time) additionally carries `provider, subsidyAmount, bonusAmount`. ```json [ diff --git a/docs/env.md b/docs/env.md index bc44a49bf..313313b07 100644 --- a/docs/env.md +++ b/docs/env.md @@ -56,6 +56,7 @@ 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 claim time, so a third party can sponsor part of a payer's cost and/or pay the node a bonus. 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; a malformed value (bad JSON, not a per-chain object, or an invalid address) 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\"] }"` ## Logs diff --git a/package-lock.json b/package-lock.json index 9c14eceb9..08dd4e014 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.0.0", "@oceanprotocol/ddo-js": "^0.4.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", @@ -2969,10 +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==", - "license": "Apache-2.0" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oceanprotocol/contracts/-/contracts-3.0.0.tgz", + "integrity": "sha512-d/P0f9H4TbGMdiAU/+NGeLxkqz3Abgs04vaA/qwn1u7PrEroZPmA4lgHAiPSLeJfsqtlnhuGGzGgNWArzawtuA==" }, "node_modules/@oceanprotocol/ddo-js": { "version": "0.4.1", diff --git a/package.json b/package.json index b347d33ab..0c642a18a 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.0.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/Escrow.ts b/src/@types/Escrow.ts index 3b248a9ae..b2359ba3d 100644 --- a/src/@types/Escrow.ts +++ b/src/@types/Escrow.ts @@ -35,4 +35,8 @@ export interface EscrowEvent { oldAmount?: string newAmount?: string newExpiry?: string + // Subsidized event fields + provider?: string + subsidyAmount?: string + bonusAmount?: string } diff --git a/src/@types/OceanNode.ts b/src/@types/OceanNode.ts index 196708031..d8071d107 100644 --- a/src/@types/OceanNode.ts +++ b/src/@types/OceanNode.ts @@ -177,6 +177,8 @@ 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 codeHash?: string rateLimit?: number // per request ip or peer maxConnections?: number // global, regardless of client address(es) @@ -264,6 +266,8 @@ 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 // detailed information c2dClusters?: any[] supportedSchemas?: Schema[] 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..2fe2e8c64 100644 --- a/src/components/Indexer/processor.ts +++ b/src/components/Indexer/processor.ts @@ -50,7 +50,8 @@ 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 } const processorInstances = new Map() diff --git a/src/components/Indexer/processors/EscrowEventProcessor.ts b/src/components/Indexer/processors/EscrowEventProcessor.ts index d23ae4340..511d3ff6c 100644 --- a/src/components/Indexer/processors/EscrowEventProcessor.ts +++ b/src/components/Indexer/processors/EscrowEventProcessor.ts @@ -100,6 +100,16 @@ 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 default: return null } diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index c8da48e51..a019e4e2d 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' @@ -1011,7 +1011,8 @@ export class C2DEngineDocker extends C2DEngine { tokens, payers, amounts, - proofs + proofs, + JobType.COMPUTE ) if (txId) { // Update all jobs with the transaction ID @@ -1041,7 +1042,8 @@ export class C2DEngineDocker extends C2DEngine { claim.job.payment!.token, claim.job.owner, claim.cost, - claim.proof + claim.proof, + JobType.COMPUTE ) if (txId) { if (claim.job.payment) { @@ -4133,7 +4135,8 @@ export class C2DEngineDocker extends C2DEngine { token, job.owner, job.payment.cost, - `service-start:${serviceId}` + `service-start:${serviceId}`, + JobType.SERVICE ) if (!claimTx) { job.payment.cancelTx = await this.safeCancelLock( diff --git a/src/components/core/service/extendService.ts b/src/components/core/service/extendService.ts index 15c952efa..ad6164707 100644 --- a/src/components/core/service/extendService.ts +++ b/src/components/core/service/extendService.ts @@ -9,6 +9,7 @@ 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' @@ -330,7 +331,8 @@ export class ServiceExtendHandler extends CommandHandler { task.payment.token, task.consumerAddress, costExtend, - `service-extend:${task.serviceId}` + `service-extend:${task.serviceId}`, + JobType.SERVICE ) } catch (e: any) { claimTx = null diff --git a/src/components/core/utils/escrow.ts b/src/components/core/utils/escrow.ts index 0d438f7ff..1542a56ad 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 { @@ -236,13 +250,15 @@ export class Escrow { token: string, payer: string, amount: number, - proof: string + proof: string, + jobType: JobType = JobType.NONE ): 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) + const subsidyProviders = this.getSubsidyProvidersForChain(chain) if (!contract) return null try { const locks = await this.getLocks(chain, token, payer, await signer.getAddress()) @@ -253,7 +269,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 +280,8 @@ export class Escrow { payer, wei, ethers.toUtf8Bytes(proof), + jobType, + subsidyProviders, gasOptions ) return tx.hash @@ -321,7 +341,8 @@ export class Escrow { tokens: string[], payers: string[], amounts: number[], - proofs: string[] + proofs: string[], + jobType: JobType = JobType.NONE ): Promise { const blockchain = this.getBlockchain(chain) const signer = await blockchain.getSigner() @@ -345,13 +366,21 @@ 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 (the batch is single-chain, so the same per-chain list is + // repeated for every job). + const chainSubsidyProviders = this.getSubsidyProvidersForChain(chain) + const jobTypes: JobType[] = jobs.map(() => jobType) + const subsidyProviders: string[][] = jobs.map(() => 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 +389,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..f3b9a0749 100644 --- a/src/components/core/utils/statusHandler.ts +++ b/src/components/core/utils/statusHandler.ts @@ -147,6 +147,9 @@ export async function status( parseInt(chain) ) } + // Per-chain Subsidy Provider contract addresses configured on this node (empty map when + // none are set). Surfaced in both normal and detailed status via this shared block. + nodeStatus.subsidyProviders = config.subsidyProviders ?? {} } // 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 diff --git a/src/test/integration/download.test.ts b/src/test/integration/download.test.ts index 3aed54e58..822b1fca7 100644 --- a/src/test/integration/download.test.ts +++ b/src/test/integration/download.test.ts @@ -75,6 +75,7 @@ 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.ADDRESS_FILE ], [ @@ -83,6 +84,7 @@ describe('********** [Download Flow] - Should run a complete node flow.' '0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58', JSON.stringify(['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260']), JSON.stringify(['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260']), + JSON.stringify({ '8996': ['0xe2DD09d719Da89e5a3D0F2549c7E24566e947260'] }), `${homedir}/.ocean/ocean-contracts/artifacts/address.json` ] ) @@ -127,6 +129,11 @@ 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.c2dClusters === undefined, 'clusters info should be undefined') assert(status.supportedSchemas === undefined, 'schemas info should be undefined') }) @@ -142,6 +149,11 @@ 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' + ) }) 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..e3ac04c3d 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 { @@ -257,6 +258,85 @@ 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' + ) + }) + 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/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..96d43e3e9 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,92 @@ 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('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)]) + }) + + after(() => { + delete process.env.CONFIG_PATH + delete process.env.PRIVATE_KEY + delete process.env[ENVIRONMENT_VARIABLES.SUBSIDY_PROVIDERS.name] + }) +}) diff --git a/src/test/unit/escrowWrapper.test.ts b/src/test/unit/escrowWrapper.test.ts new file mode 100644 index 000000000..1e7f6fc5d --- /dev/null +++ b/src/test/unit/escrowWrapper.test.ts @@ -0,0 +1,141 @@ +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) + return { claimLockAndWithdraw, claimLocksAndWithdraw } + } + + // 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([[], []]) + }) +}) diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index fe18f2efd..c82b67f14 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,8 @@ 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) // two writes: the durable intent (before claim) + the finalized extension expect(engine.db.updateServiceJob.calledTwice).to.equal(true) const out = await body(res) diff --git a/src/utils/config/constants.ts b/src/utils/config/constants.ts index 924a6526a..8c8a1644b 100644 --- a/src/utils/config/constants.ts +++ b/src/utils/config/constants.ts @@ -34,6 +34,7 @@ export const ENV_TO_CONFIG_MAPPING = { AUTHORIZED_PUBLISHERS_LIST: 'authorizedPublishersList', ALLOWED_ADMINS: 'allowedAdmins', ALLOWED_ADMINS_LIST: 'allowedAdminsList', + SUBSIDY_PROVIDERS: 'subsidyProviders', 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..166fae0e0 100644 --- a/src/utils/config/schemas.ts +++ b/src/utils/config/schemas.ts @@ -99,6 +99,37 @@ 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 claim time. Same shape as AccessListContract, but the addresses are normalized +// to their EIP-55 checksummed form via ethers `getAddress`. Anything malformed (bad JSON, not a +// per-chain object, or an invalid address) collapses 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)) { + if (!Array.isArray(addresses)) return null + checksummed[chainId] = addresses.map((addr) => getAddress(addr as string)) + } + 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 +1170,7 @@ export const OceanNodeConfigSchema = z }), allowedAdmins: addressArrayFromString.optional(), allowedAdminsList: jsonFromString(AccessListContractSchema).optional(), + subsidyProviders: SubsidyProvidersSchema.optional().default(null), codeHash: z.string().optional(), maxConnections: z.coerce.number().optional(), diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f62ed9ec4..e14f6b005 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,8 @@ export const EVENTS = { ESCROW_CLAIMED: 'Claimed', ESCROW_CANCELED: 'Canceled', ESCROW_DEPOSIT: 'Deposit', - ESCROW_WITHDRAW: 'Withdraw' + ESCROW_WITHDRAW: 'Withdraw', + ESCROW_SUBSIDIZED: 'Subsidized' } export const ESCROW_EVENTS = [ @@ -174,7 +187,8 @@ export const ESCROW_EVENTS = [ EVENTS.ESCROW_CLAIMED, EVENTS.ESCROW_CANCELED, EVENTS.ESCROW_DEPOSIT, - EVENTS.ESCROW_WITHDRAW + EVENTS.ESCROW_WITHDRAW, + EVENTS.ESCROW_SUBSIDIZED ] export const INDEXER_CRAWLING_EVENTS = { @@ -284,6 +298,10 @@ 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)' } } @@ -421,6 +439,12 @@ 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 + }, ASSET_PURGATORY_URL: { name: 'ASSET_PURGATORY_URL', value: process.env.ASSET_PURGATORY_URL, From 48e034ee981bcb79a15837952b96f30a3e29c137 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Thu, 24 Sep 2026 11:29:02 +0300 Subject: [PATCH 2/2] fix review --- src/components/core/utils/statusHandler.ts | 7 ++++--- src/test/integration/escrow.test.ts | 4 ++++ src/test/unit/config.test.ts | 8 ++++++++ src/utils/config/schemas.ts | 4 ++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/core/utils/statusHandler.ts b/src/components/core/utils/statusHandler.ts index f3b9a0749..271c6b9ce 100644 --- a/src/components/core/utils/statusHandler.ts +++ b/src/components/core/utils/statusHandler.ts @@ -147,10 +147,11 @@ export async function status( parseInt(chain) ) } - // Per-chain Subsidy Provider contract addresses configured on this node (empty map when - // none are set). Surfaced in both normal and detailed status via this shared block. - nodeStatus.subsidyProviders = config.subsidyProviders ?? {} } + // 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 ?? {} // 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/test/integration/escrow.test.ts b/src/test/integration/escrow.test.ts index e3ac04c3d..116acc16b 100644 --- a/src/test/integration/escrow.test.ts +++ b/src/test/integration/escrow.test.ts @@ -335,6 +335,10 @@ describe('Indexer stores Escrow contract events', () => { 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 () { diff --git a/src/test/unit/config.test.ts b/src/test/unit/config.test.ts index 96d43e3e9..4d9add018 100644 --- a/src/test/unit/config.test.ts +++ b/src/test/unit/config.test.ts @@ -287,6 +287,14 @@ describe('Should validate SUBSIDY_PROVIDERS configuration', () => { 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] }) diff --git a/src/utils/config/schemas.ts b/src/utils/config/schemas.ts index 166fae0e0..14aaeaf80 100644 --- a/src/utils/config/schemas.ts +++ b/src/utils/config/schemas.ts @@ -118,6 +118,10 @@ export const SubsidyProvidersSchema = z.preprocess( 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 checksummed[chainId] = addresses.map((addr) => getAddress(addr as string)) }