Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2ff99ed
feat(telemetry): RPC usage accounting — raw JSON-RPC request counts p…
Bojan131 Jul 2, 2026
9267a2a
fix(rpc-usage): count ethers retry attempts + exact-equality tests + …
Bojan131 Jul 2, 2026
27fdd6a
refactor(rpc-usage): one typed RpcUsageWindow contract + parsing out …
Bojan131 Jul 2, 2026
73a5df9
refactor(rpc-usage): narrow the public barrel to the RpcUsageWindow t…
Bojan131 Jul 2, 2026
b31c6a6
fix(rpc-usage): effective chainId on log lines + one transport factor…
Bojan131 Jul 2, 2026
5e29e8d
refactor(rpc-usage): extract the scheduling lifecycle out of runDaemo…
Bojan131 Jul 2, 2026
ea99cb6
test(rpc-usage): cover the adapter→agent→daemon telemetry boundary (#…
Bojan131 Jul 2, 2026
8759364
fix(rpc-usage): window method cap + multi-endpoint failover coverage …
Bojan131 Jul 3, 2026
346fc0b
chore(rpc-usage): drop stray localhost deployment snapshot from the PR
Bojan131 Jul 3, 2026
4ec9806
docs(rpc-usage): RpcUsageWindow.byMethod contract says raw, count-cap…
Bojan131 Jul 3, 2026
90d9de4
fix(rpc-usage): include async-publisher adapters in the drained windo…
Bojan131 Jul 3, 2026
e641491
Merge origin/main into feat/rpc-usage-metrics (resolve publisher-runn…
Bojan131 Jul 3, 2026
ef5488c
refactor(rpc-usage): mergeRpcUsageWindows lives in the chain model la…
Bojan131 Jul 3, 2026
5a075c6
test(rpc-usage): real publisher-runtime boundary coverage + retry pol…
Bojan131 Jul 3, 2026
105c001
chore(rpc-usage): pin localhost_contracts.json to main (local devnet …
Bojan131 Jul 3, 2026
e25b194
refactor(rpc-usage): one drain contract + two-wallet boundary test + …
Bojan131 Jul 3, 2026
8726329
refactor(rpc-usage): wallet model owns its chain adapter — no paralle…
Bojan131 Jul 3, 2026
42a8781
refactor(rpc-usage): drainables always return a concrete window (review)
Bojan131 Jul 3, 2026
5901102
refactor(rpc-usage): total is derived, not stored (review)
Bojan131 Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/agent/src/dkg-agent-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import {
pickNetworkTunables,
} from '@origintrail-official/dkg-core';
import { GraphManager, PrivateContentStore, createTripleStore, isExternalBackend, type TripleStore, type TripleStoreConfig, type Quad, type LargeLiteralStorageConfig } from '@origintrail-official/dkg-storage';
import { EVMChainAdapter, NoChainAdapter, enrichEvmError, buildKnowledgeAssetUal, type EVMAdapterConfig, type ChainAdapter, type CreateContextGraphParams, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type TxResult, type V10PublishingConvictionAccountInfo } from '@origintrail-official/dkg-chain';
import { emptyRpcUsageWindow, EVMChainAdapter, NoChainAdapter, enrichEvmError, buildKnowledgeAssetUal, type EVMAdapterConfig, type ChainAdapter, type CreateContextGraphParams, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type TxResult, type V10PublishingConvictionAccountInfo, type RpcUsageWindow } from '@origintrail-official/dkg-chain';
import {
DKGPublisher, PublishHandler, SharedMemoryHandler, UpdateHandler, ChainEventPoller, AccessHandler, AccessClient,
PublishJournal, StaleWriteError,
Expand Down Expand Up @@ -1405,4 +1405,15 @@ export class DKGAgentBase {
this.publisher.setWorkspaceSenderKeyEncryptor((input) => (this as unknown as DKGAgent).encryptWorkspacePayloadWithSenderKey(input));
this.syncCheckpoints = config.syncCheckpointStore ?? this.syncCheckpoints;
}

/**
* RpcUsageDrainable: drain the chain adapter's raw JSON-RPC usage window
* (delta since the previous drain — the provider-billing unit). The
* adapter capability is optional; an adapter without it reports an empty
* window here, so consumers never see the optionality. Keeps `chain`
* protected instead of the daemon reaching through a cast.
*/
drainRpcUsage(): RpcUsageWindow {
return this.chain.drainRpcUsage?.() ?? emptyRpcUsageWindow();
}
}
44 changes: 44 additions & 0 deletions packages/agent/test/rpc-usage-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
/**
* Review coverage gap (PR #1409): the agent's `drainRpcUsage()` is THE
* boundary the daemon telemetry consumes (`startRpcUsageTelemetry({ source:
* agent })`), and a delegation regression here would silence every rpc_usage
* log while the chain-side and cli-side tests stayed green. These drive the
* REAL method (prototype-invoked with a chain-bearing `this`, the established
* pattern for agent unit tests) against the real MockChainAdapter, a
* counts-bearing adapter, and an adapter lacking the capability.
*/
import { describe, it, expect } from 'vitest';
import { DKGAgentBase } from '../src/dkg-agent-base.js';
import { DKGAgent } from '../src/dkg-agent.js';
import { MockChainAdapter } from '@origintrail-official/dkg-chain';

const drain = DKGAgentBase.prototype.drainRpcUsage;

describe('DKGAgent.drainRpcUsage — the adapter→agent telemetry boundary', () => {
it('delegates to the adapter and passes the window through VERBATIM', () => {
const window = { byMethod: { eth_call: 42, eth_getLogs: 7 }, lifetimeTotal: 49 };
const out = drain.call({ chain: { drainRpcUsage: () => window } } as never);
expect(out).toBe(window); // same object — no reshaping/copying in the boundary
});

it('returns the real MockChainAdapter empty window (capability present, no transport)', () => {
const chain = new MockChainAdapter();
const out = drain.call({ chain } as never);
expect(out).toEqual({ byMethod: {}, lifetimeTotal: 0 });
});

it('collapses a missing adapter capability to a concrete EMPTY window (consumers never see undefined)', () => {
const out = drain.call({ chain: {} } as never);
expect(out).toEqual({ byMethod: {}, lifetimeTotal: 0 });
});

it('is inherited by the composed DKGAgent class (the daemon consumes agent, not the base)', () => {
// The daemon passes the DKGAgent instance as the telemetry source — the
// method must exist on the composed class's prototype chain.
expect(typeof DKGAgent.prototype.drainRpcUsage).toBe('function');
const window = { byMethod: { eth_sendRawTransaction: 3 }, lifetimeTotal: 3 };
const out = DKGAgent.prototype.drainRpcUsage.call({ chain: { drainRpcUsage: () => window } } as never);
expect(out).toBe(window);
});
});
11 changes: 11 additions & 0 deletions packages/chain/src/chain-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ethers } from 'ethers';
import type { RpcUsageWindow } from './rpc-usage.js';

/**
* The Publishing-Conviction-Account read methods the funded-wallet selector
Expand Down Expand Up @@ -797,6 +798,16 @@ export interface ChainAdapter {
*/
deploymentId: string;

/**
* OPTIONAL RPC-usage capability: drain the raw JSON-RPC request counts
* accumulated since the previous drain (a DELTA window — summing drains over
* time yields exact request totals, the provider-billing unit). The EVM
* adapter counts at its HTTP transport; the mock adapter returns an
* always-empty window (it has no RPC transport). Consumed by the daemon's
* minutely `rpc_usage` telemetry log line.
*/
drainRpcUsage?(): RpcUsageWindow;

// Identity
registerIdentity(proof: IdentityProof): Promise<bigint>;
getIdentityId(): Promise<bigint>;
Expand Down
24 changes: 23 additions & 1 deletion packages/chain/src/evm-adapter-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { resolveRpcUrls, boundedRetryFetchRequest, withTimeout, isRetryableRpcEr
import { rpcHost } from './rpc-failover-log.js';
import { ChainRpcTransportError, createRpcTimeoutError } from './chain-rpc-transport-error.js';
import { RpcFailoverClient, type ReadOpts } from './rpc-failover-client.js';
import { RpcUsageTracker, createCountingJsonRpcProvider, type RpcUsageWindow } from './rpc-usage.js';
import { computeApprovalAction, effectivePublishAllowance, V10_PUBLISH_ONCHAIN_MIN_ALLOWANCE } from './evm-adapter-allowance.js';
import { formatProviderContext } from './evm-adapter-types.js';
import type { ContractCache, EVMAdapterConfig } from './evm-adapter-types.js';
Expand Down Expand Up @@ -343,6 +344,8 @@ export class EVMChainAdapterBase {
* the adapter and never owns tx-safety state.
*/
protected readonly rpcFailover: RpcFailoverClient;
/** Raw JSON-RPC request accounting (provider-billing unit). See rpc-usage.ts. */
protected readonly rpcUsage: RpcUsageTracker;

protected readonly filterErrorSilencer: FilterErrorSilencer;

Expand Down Expand Up @@ -625,8 +628,16 @@ export class EVMChainAdapterBase {
// single-RPC node keeps the bounded `RPC_REQUEST_MAX_RETRIES` retry (its
// only resilience; #894) via the default. See `boundedRetryFetchRequest`.
const perEndpointRetries = this.rpcUrls.length > 1 ? 0 : undefined;
// CountingJsonRpcProvider reports every raw JSON-RPC request to the usage
// tracker (the PROVIDER-BILLING unit — see rpc-usage.ts). With
// `batchMaxCount: 1` below, one send() == one HTTP request, so the count is
// exact. `this.chainId` is assigned later in this constructor → live thunk.
this.rpcUsage = new RpcUsageTracker(() => this.chainId);
// One transport factory wires BOTH billing-exact accounting hooks (first
// attempt at `_send` + every ethers-internal retry attempt) to the tracker —
// see createCountingJsonRpcProvider for the invariant.
this.providers = this.rpcUrls.map(
(url) => new JsonRpcProvider(boundedRetryFetchRequest(url, perEndpointRetries), undefined, {
(url) => createCountingJsonRpcProvider(url, perEndpointRetries, this.rpcUsage, {
cacheTimeout: -1,
polling: true,
batchMaxCount: 1,
Expand Down Expand Up @@ -2734,6 +2745,17 @@ export class EVMChainAdapterBase {
return [...this.rpcUrls];
}

/**
* Drain the raw JSON-RPC request counts accumulated since the previous drain
* (delta window) — consumed by the daemon's minutely `rpc_usage` telemetry
* log line. Part of the optional `ChainAdapter.drainRpcUsage` capability
* (the mock adapter implements it as an always-empty window — it has no RPC
* transport).
*/
drainRpcUsage(): RpcUsageWindow {
return this.rpcUsage.drainWindow();
}

async getContract(name: string): Promise<Contract> {
await this.init();
return this.resolveContract(name);
Expand Down
2 changes: 1 addition & 1 deletion packages/chain/src/evm-adapter-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function boundedRetryFetchRequest(
maxRetries: number = RPC_REQUEST_MAX_RETRIES,
): FetchRequest {
const req = new FetchRequest(url);
req.retryFunc = async (_req, _response, attempt) => {
req.retryFunc = async (_attemptReq, _response, attempt) => {
if (attempt >= maxRetries) return false;
await sleep(Math.min(500 * (attempt + 1), RPC_REQUEST_RETRY_BACKOFF_CAP_MS));
return true;
Expand Down
6 changes: 6 additions & 0 deletions packages/chain/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export * from './chain-adapter.js';
// RPC-usage accounting: ONLY the typed window contract is public API (consumed
// by the ChainAdapter.drainRpcUsage capability, the agent boundary, and the
// daemon's rpc_usage log emission). The parsing/label-bounding helpers stay
// package-internal (imported via ./rpc-usage.js) so the transport accounting
// implementation can change without a public-API break.
export { emptyRpcUsageWindow, mergeRpcUsageWindows, rpcUsageWindowTotal, type RpcUsageDrainable, type RpcUsageWindow } from './rpc-usage.js';
export { MockChainAdapter, MOCK_DEFAULT_SIGNER } from './mock-adapter.js';
export {
EVMChainAdapter,
Expand Down
6 changes: 6 additions & 0 deletions packages/chain/src/mock-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
NodePublishingConvictionAccount,
VerifyACKIdentityResult,
} from './chain-adapter.js';
import type { RpcUsageWindow } from './rpc-usage.js';
import {
NoEligibleContextGraphError,
NoEligibleKnowledgeCollectionError,
Expand Down Expand Up @@ -125,6 +126,11 @@ export class MockChainAdapter implements ChainAdapter {
return [];
}

/** RPC-usage capability: the mock has no RPC transport → always-empty window. */
drainRpcUsage(): RpcUsageWindow {
return { byMethod: {}, lifetimeTotal: 0 };
}

async ensureProfile(_options?: { nodeName?: string; stakeAmount?: bigint; lockTier?: number }): Promise<bigint> {
const existing = await this.getIdentityId();
if (existing > 0n) return existing;
Expand Down
Loading
Loading