diff --git a/.agent/context/20260913T-graph-transaction-evidence.md b/.agent/context/20260913T-graph-transaction-evidence.md
new file mode 100644
index 0000000..e7d3000
--- /dev/null
+++ b/.agent/context/20260913T-graph-transaction-evidence.md
@@ -0,0 +1,81 @@
+# Session Context: graph-transaction-evidence
+
+## Date/time
+
+- UTC: 2026-09-13
+
+## User goal
+
+Make The Graph call and display evidence for transactions performed through the
+site, regardless of whether the OneShot request is confirmed, failed safely, or
+remains uncertain.
+
+## Original prompt/request
+
+“Our the graph is never called and give any info on our transactions. Fix it so
+The Graph shows evidence for transactions performed via our site, whether the
+transaction failed or was approved.”
+
+## Assumptions
+
+- The Graph remains non-authoritative; OneShot and Arc receipt evidence decide
+ settlement state.
+- A failed/rejected request may have no indexed ERC-20 Transfer event. The UI
+ must show that state and say that missing Graph data is not proof of no
+ payment.
+- User-wallet payer addresses must be discovered from durable workspace jobs;
+ the configured server wallet remains an optional fallback for server-wallet
+ activity.
+- The pre-existing edit to the prior session context remains user-owned.
+
+## Plan
+
+1. Make API Graph activity use all durable workspace payer wallets and refresh
+ automatically from the cabinet.
+2. Return and render a workspace transaction ledger with Graph match status for
+ every site request outcome.
+3. Enqueue durable Graph evidence capture for confirmed, failed-safe, unknown,
+ and rejected lifecycle outcomes.
+4. Add focused API, storage, worker, and browser/UI regression coverage.
+
+## Key decisions
+
+- A missing indexed transfer is displayed as `NOT_INDEXED`, never as proof that
+ a payment did not happen.
+- A failed Graph read is displayed as `UNAVAILABLE`, not as a negative payment
+ result.
+- Failed-safe and rejected requests are represented in the site transaction
+ ledger even when no transaction hash exists.
+- Graph transport failures remain observable as unavailable activity and do not
+ change payment state or create retry permission.
+
+## Branch state
+
+- Branch: `fix/graph-transaction-evidence`
+- Base: refreshed `origin/develop` at `65200cc2dfcf22912e532a157232e439d623044f`.
+- Commit/PR: not created.
+- Gate A/B: not started.
+
+## Checks
+
+- Policy and routed idempotency/failure-injection documents read.
+- `pnpm --filter @oneshot/contracts check:generated` passed.
+- `pnpm lint`, `pnpm typecheck`, and `pnpm build` passed.
+- Focused API/storage/worker/web suites passed.
+- Full `pnpm test` passed: 80 files, 1,057 tests.
+- `pnpm test:browser` passed: 8 browser tests.
+- `pnpm test:integration` loaded all integration suites but skipped them because
+ this workstation has no container runtime.
+- No commit, push, PR, deployment, or FreePi Gate A/B run has been performed
+ yet; these are pending the explicit push/PR request.
+
+## Unresolved questions
+
+- The Graph indexes successful ERC-20 transfer events; reverted/no-transfer
+ transactions cannot be fabricated into the subgraph. They will be shown with
+ their OneShot outcome and explicit non-proof wording.
+
+## Handoff/next steps
+
+Stage the scoped tree, run Gate A, commit, push, open the draft PR, wait for
+required CI, and run Gate B before handing off for human review.
diff --git a/.env.example b/.env.example
index f5b8956..aad87ca 100644
--- a/.env.example
+++ b/.env.example
@@ -53,9 +53,13 @@ ONESHOT_SUBGRAPH_QUERY_URL=https://api.studio.thegraph.com/query//
-# Optional bounded manual wallet-activity refresh for the authenticated cabinet.
-# When unset, activity reports Graph as unavailable without affecting payments.
+# The API uses ONESHOT_SUBGRAPH_QUERY_URL automatically for the authenticated
+# cabinet. Keep this legacy variable only when the activity path needs a
+# different pinned deployment; it overrides the canonical URL.
# ONESHOT_GRAPH_QUERY_URL=https://api.studio.thegraph.com/query///
+# Optional server-wallet fallback for Graph activity. User-wallet payer
+# addresses are discovered from durable workspace jobs automatically.
+# ONESHOT_ACTIVITY_WALLET_ADDRESS=0x<40-hex-server-wallet-address>
# ONESHOT_SUBGRAPH_MCP_SERVER_VERSION=1.0.0
ONESHOT_SUBGRAPH_DEPLOYMENT_ID=0x<64-hex-deployment-id>
ONESHOT_SUBGRAPH_MANIFEST_CID=
diff --git a/README.md b/README.md
index 9c2a42d..21a2bb0 100644
--- a/README.md
+++ b/README.md
@@ -173,10 +173,13 @@ frozen `recovery-view` API into the C05 timeline model, with labelled
fail-closed fallbacks for legacy or unavailable evidence. The P5 browser
acceptance suite runs with Playwright/Chromium in CI.
-Authenticated wallet activity is read-only: the API records bounded Graph
-observations, links indexed transfers to settlements in the configured
-workspace, and surfaces unmatched transfers. Graph absence or lag never changes
-payment authority.
+Authenticated site activity is read-only: the API automatically queries the
+configured Arc subgraph for every payer wallet recorded in the workspace,
+records bounded Graph observations, and displays one audit row for every site
+payment request, including rejected, failed-safe, uncertain, and committed
+outcomes. Indexed transfers are linked to settlements and unmatched transfers
+remain visible. Graph absence or lag never changes payment authority; a missing
+or reverted transfer event is not proof that no payment happened.
Integration tests need a database:
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 671f33b..22500d9 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -61,6 +61,7 @@ export interface ApiDependencies {
| 'list'
| 'resumeDelivery'
| 'recordActivityObservation'
+ | 'activityPayerWallets'
| 'activity'
>;
readonly supplier?: SupplierPort;
@@ -143,6 +144,38 @@ export function buildApi(dependencies: ApiDependencies) {
const workspaceFor = (request: FastifyRequest): string =>
requestWorkspaces.get(request) ?? defaultWorkspaceId;
const walletActivity = dependencies.walletActivity ?? new UnavailableWalletActivityPort();
+ async function refreshGraphActivity(
+ workspaceId: string,
+ additionalWallet?: string,
+ ): Promise {
+ // A missing activity port is the deliberate local/test fallback. The
+ // configured production port is queried after user-wallet outcomes as
+ // well as from the cabinet refresh, so Graph is not recovery-only.
+ if (!dependencies.walletActivity || !dependencies.jobs?.recordActivityObservation) return;
+ try {
+ const wallets = dependencies.jobs.activityPayerWallets
+ ? await dependencies.jobs.activityPayerWallets(workspaceId)
+ : additionalWallet
+ ? [additionalWallet]
+ : [];
+ const normalizedAdditionalWallet = additionalWallet?.toLowerCase();
+ const observation = await dependencies.walletActivity.refresh(
+ normalizedAdditionalWallet &&
+ !wallets.some((wallet) => wallet.toLowerCase() === normalizedAdditionalWallet)
+ ? [...wallets, normalizedAdditionalWallet]
+ : wallets,
+ );
+ await dependencies.jobs.recordActivityObservation({
+ workspaceId,
+ freshness: observation.freshness,
+ coverageNote: observation.coverageNote,
+ payload: observation.payload,
+ });
+ } catch {
+ // Activity is read-only evidence. A provider failure must not change the
+ // payment response or turn a missing index row into a no-payment claim.
+ }
+ }
const jobsUnavailable = (reply: FastifyReply, request: FastifyRequest): void =>
sendError(
reply,
@@ -621,6 +654,7 @@ export function buildApi(dependencies: ApiDependencies) {
);
return;
}
+ await refreshGraphActivity(workspaceFor(request), job.user_payment.payer_wallet);
return reply.code(updated.payment_state === 'UNKNOWN' ? 202 : 200).send(updated);
},
);
@@ -686,7 +720,10 @@ export function buildApi(dependencies: ApiDependencies) {
jobsUnavailable(reply, request);
return;
}
- const observation = await walletActivity.refresh();
+ const wallets = dependencies.jobs.activityPayerWallets
+ ? await dependencies.jobs.activityPayerWallets(workspaceFor(request))
+ : [];
+ const observation = await walletActivity.refresh(wallets);
await dependencies.jobs.recordActivityObservation({
workspaceId: workspaceFor(request),
freshness: observation.freshness,
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index c4f1973..3c3c53c 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -22,7 +22,8 @@ export interface ApiRuntimeConfig {
readonly privyAuth?: PrivyAuthRuntimeConfig;
readonly walletActivity?: {
readonly endpoint: string;
- readonly wallet: string;
+ /** Optional server-wallet fallback; user-wallet payers come from the workspace ledger. */
+ readonly wallet?: string;
readonly apiKey?: string;
};
/** Credential-free read-only RPC used to verify user-submitted receipts. */
@@ -201,13 +202,18 @@ export function loadApiRuntimeConfig(
): ApiRuntimeConfig {
const workspaceId = environment.ONESHOT_WORKSPACE_ID?.trim() || 'default-workspace';
const privyAuth = privyAuthConfig(environment);
- const activityEndpoint = environment.ONESHOT_GRAPH_QUERY_URL?.trim();
+ // The worker and API must query the same pinned Studio deployment. Keep the
+ // older activity-specific variable as an explicit override for deployments
+ // that still use it, but make the worker's canonical subgraph URL sufficient
+ // for the site activity path too.
+ const activityEndpoint =
+ environment.ONESHOT_GRAPH_QUERY_URL?.trim() || environment.ONESHOT_SUBGRAPH_QUERY_URL?.trim();
const activityWallet = environment.ONESHOT_ACTIVITY_WALLET_ADDRESS?.trim();
const userWalletRpcUrl = optionalRpcUrl(environment, 'ONESHOT_ARC_RPC_URL');
const mcp = mcpConfig(environment, workspaceId);
- if ((activityEndpoint && !activityWallet) || (!activityEndpoint && activityWallet)) {
+ if (!activityEndpoint && activityWallet) {
throw new Error(
- 'ONESHOT_GRAPH_QUERY_URL and ONESHOT_ACTIVITY_WALLET_ADDRESS must be configured together',
+ 'A Graph query URL is required when ONESHOT_ACTIVITY_WALLET_ADDRESS is configured',
);
}
if (activityEndpoint) {
@@ -231,11 +237,11 @@ export function loadApiRuntimeConfig(
windowMs: integer(environment, 'ONESHOT_API_RATE_LIMIT_WINDOW_MS', 60_000, 1_000, 3_600_000),
},
...(privyAuth ? { privyAuth } : {}),
- ...(activityEndpoint && activityWallet
+ ...(activityEndpoint
? {
walletActivity: {
endpoint: activityEndpoint,
- wallet: activityWallet,
+ ...(activityWallet ? { wallet: activityWallet } : {}),
...(environment.ONESHOT_GRAPH_API_KEY?.trim()
? { apiKey: environment.ONESHOT_GRAPH_API_KEY.trim() }
: {}),
diff --git a/apps/api/src/wallet-activity.ts b/apps/api/src/wallet-activity.ts
index 9f3feb5..cb118ee 100644
--- a/apps/api/src/wallet-activity.ts
+++ b/apps/api/src/wallet-activity.ts
@@ -8,6 +8,11 @@ export interface WalletActivitySnapshot {
readonly transfers: readonly {
readonly transaction_hash: string;
readonly log_index: number;
+ readonly sender?: string;
+ readonly token_contract?: string;
+ readonly block_number?: string;
+ readonly block_timestamp?: string;
+ readonly network?: 'eip155:5042002';
readonly recipient: string;
readonly amount_atomic: string;
}[];
@@ -15,21 +20,57 @@ export interface WalletActivitySnapshot {
}
export interface WalletActivityPort {
- refresh(): Promise;
+ refresh(wallets?: readonly string[]): Promise;
}
-const QUERY = `query OneShotWalletActivity($sender: Bytes!) { settlementCandidates(first: 100, orderBy: blockNumber, orderDirection: desc, where: { sender: $sender }) { transactionHash logIndex recipient amountAtomic } _meta { deployment hasIndexingErrors block { number } } }`;
+const QUERY = `query OneShotWalletActivity($senders: [Bytes!]!) { settlementCandidates(first: 100, orderBy: blockNumber, orderDirection: desc, where: { sender_in: $senders }) { transactionHash logIndex sender tokenContract blockNumber blockTimestamp network recipient amountAtomic } _meta { deployment hasIndexingErrors block { number } } }`;
+
+function graphBlockTimestamp(value: unknown): string | undefined {
+ if (value === undefined) return undefined;
+ if (
+ typeof value === 'string' &&
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/u.test(value)
+ ) {
+ return value;
+ }
+ if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/u.test(value)) {
+ throw new Error('Graph activity block timestamp failed validation');
+ }
+ const seconds = Number(value);
+ if (!Number.isSafeInteger(seconds) || seconds < 0) {
+ throw new Error('Graph activity block timestamp failed validation');
+ }
+ const timestamp = new Date(seconds * 1_000);
+ if (Number.isNaN(timestamp.getTime())) {
+ throw new Error('Graph activity block timestamp failed validation');
+ }
+ return timestamp.toISOString();
+}
export class StudioWalletActivityPort implements WalletActivityPort {
constructor(
private readonly options: {
readonly endpoint: string;
- readonly wallet: string;
+ readonly wallet?: string;
readonly apiKey?: string;
readonly fetchFn?: typeof fetch;
},
) {}
- async refresh(): Promise {
+ async refresh(wallets: readonly string[] = []): Promise {
+ const senders = [...(this.options.wallet ? [this.options.wallet] : []), ...wallets].reduce<
+ string[]
+ >((unique, wallet) => {
+ const address = asEvmAddress(wallet).toLowerCase();
+ if (!unique.includes(address)) unique.push(address);
+ return unique;
+ }, []);
+ if (senders.length === 0) {
+ return {
+ freshness: 'UNAVAILABLE',
+ coverageNote: 'No site payer wallet is recorded for this workspace yet.',
+ payload: { transfers: [] },
+ };
+ }
const response = await (this.options.fetchFn ?? fetch)(this.options.endpoint, {
method: 'POST',
headers: {
@@ -38,7 +79,7 @@ export class StudioWalletActivityPort implements WalletActivityPort {
},
body: JSON.stringify({
query: QUERY,
- variables: { sender: asEvmAddress(this.options.wallet) },
+ variables: { senders },
}),
});
if (!response.ok) throw new Error('Graph activity query is unavailable');
@@ -70,6 +111,17 @@ export class StudioWalletActivityPort implements WalletActivityPort {
return {
transaction_hash: asTransactionHash(row.transactionHash),
log_index: index,
+ ...(typeof row.sender === 'string' ? { sender: asEvmAddress(row.sender) } : {}),
+ ...(typeof row.tokenContract === 'string'
+ ? { token_contract: asEvmAddress(row.tokenContract) }
+ : {}),
+ ...(typeof row.blockNumber === 'string' && /^(0|[1-9][0-9]*)$/u.test(row.blockNumber)
+ ? { block_number: row.blockNumber }
+ : {}),
+ ...(row.blockTimestamp === undefined
+ ? {}
+ : { block_timestamp: graphBlockTimestamp(row.blockTimestamp)! }),
+ ...(row.network === 'eip155:5042002' ? { network: 'eip155:5042002' as const } : {}),
recipient: asEvmAddress(row.recipient),
amount_atomic: row.amountAtomic,
};
diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts
index 8bfbe22..ada20a9 100644
--- a/apps/api/test/app.test.ts
+++ b/apps/api/test/app.test.ts
@@ -584,6 +584,7 @@ describe('resumable job API boundary', () => {
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
};
},
@@ -677,6 +678,7 @@ describe('resumable job API boundary', () => {
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
});
diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts
index fa68c06..15d5319 100644
--- a/apps/api/test/config.test.ts
+++ b/apps/api/test/config.test.ts
@@ -68,6 +68,16 @@ describe('API runtime configuration', () => {
});
});
+ it('uses the canonical worker Graph URL for automatic site activity', () => {
+ const config = loadApiRuntimeConfig({
+ ...base,
+ ONESHOT_SUBGRAPH_QUERY_URL: 'https://api.studio.thegraph.com/query/oneshot/arc/1',
+ });
+ expect(config.walletActivity).toEqual({
+ endpoint: 'https://api.studio.thegraph.com/query/oneshot/arc/1',
+ });
+ });
+
it('loads an isolated MCP configuration', () => {
const config = loadApiRuntimeConfig({
...base,
diff --git a/apps/api/test/wallet-activity.test.ts b/apps/api/test/wallet-activity.test.ts
index 78cc14e..7d6f67a 100644
--- a/apps/api/test/wallet-activity.test.ts
+++ b/apps/api/test/wallet-activity.test.ts
@@ -2,6 +2,33 @@ import { describe, expect, it } from 'vitest';
import { StudioWalletActivityPort } from '../src/index.js';
describe('StudioWalletActivityPort', () => {
+ it('queries all payer wallets recorded by the site', async () => {
+ let request: { variables?: { senders?: string[] } } | undefined;
+ const port = new StudioWalletActivityPort({
+ endpoint: 'https://graph.example.test/graphql',
+ fetchFn: async (_input, init) => {
+ request = JSON.parse(String(init?.body)) as typeof request;
+ return new Response(
+ JSON.stringify({
+ data: { settlementCandidates: [], _meta: { deployment: 'studio-deployment' } },
+ }),
+ { status: 200 },
+ );
+ },
+ });
+
+ await port.refresh([
+ '0x1111111111111111111111111111111111111111',
+ '0x2222222222222222222222222222222222222222',
+ '0x1111111111111111111111111111111111111111',
+ ]);
+
+ expect(request?.variables?.senders).toEqual([
+ '0x1111111111111111111111111111111111111111',
+ '0x2222222222222222222222222222222222222222',
+ ]);
+ });
+
it('validates Graph activity and reports indexed coverage', async () => {
const port = new StudioWalletActivityPort({
endpoint: 'https://graph.example.test/graphql',
@@ -14,6 +41,11 @@ describe('StudioWalletActivityPort', () => {
{
transactionHash: `0x${'a'.repeat(64)}`,
logIndex: '3',
+ sender: '0x1111111111111111111111111111111111111111',
+ tokenContract: '0x3600000000000000000000000000000000000000',
+ blockNumber: '98',
+ blockTimestamp: '1726200000',
+ network: 'eip155:5042002',
recipient: '0x2222222222222222222222222222222222222222',
amountAtomic: '2500000',
},
@@ -38,6 +70,11 @@ describe('StudioWalletActivityPort', () => {
{
transaction_hash: `0x${'a'.repeat(64)}`,
log_index: 3,
+ sender: '0x1111111111111111111111111111111111111111',
+ token_contract: '0x3600000000000000000000000000000000000000',
+ block_number: '98',
+ block_timestamp: new Date(1726200000 * 1_000).toISOString(),
+ network: 'eip155:5042002',
recipient: '0x2222222222222222222222222222222222222222',
amount_atomic: '2500000',
},
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 1d8d545..e78dd4a 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import type { ActivityResponse } from '@oneshot/contracts';
import { createSettlementClient, type SettlementClient } from '@oneshot/settlement-ui';
import type { RecoveryClient } from '@oneshot/recovery-ui';
@@ -149,6 +149,28 @@ function CabinetPage(props: {
const [intentId, setIntentId] = useState('');
const [activity, setActivity] = useState(null);
const [activityError, setActivityError] = useState(null);
+ const workspaceUnlocked =
+ props.session.status === 'SIGNED_IN' || props.machineToken.trim() !== '';
+ const refreshActivity = useCallback(async (): Promise => {
+ if (!workspaceUnlocked || typeof props.jobClient.refreshActivity !== 'function') return;
+ setActivityError(null);
+ try {
+ setActivity(await props.jobClient.refreshActivity());
+ } catch {
+ setActivityError(
+ 'Payment activity is unavailable right now. Existing payment records are unchanged.',
+ );
+ }
+ }, [props.jobClient, workspaceUnlocked]);
+
+ useEffect(() => {
+ void refreshActivity();
+ }, [refreshActivity]);
+
+ useEffect(() => {
+ if (section === 'protection') void refreshActivity();
+ }, [refreshActivity, section]);
+
const labels = {
overview: 'Overview',
services: 'Payment services',
@@ -298,17 +320,7 @@ function CabinetPage(props: {
intentId={intentId}
recoveryClient={props.recoveryClient}
settlementClient={props.settlementClient}
- onRefresh={() => {
- setActivityError(null);
- void props.jobClient
- .refreshActivity()
- .then(setActivity)
- .catch(() => {
- setActivityError(
- 'Payment activity is unavailable right now. Existing payment records are unchanged.',
- );
- });
- }}
+ onRefresh={() => void refreshActivity()}
/>
)}
{section === 'profile' && }
diff --git a/apps/web/src/components/WorkspacePanels.tsx b/apps/web/src/components/WorkspacePanels.tsx
index 7919307..c2fcaaa 100644
--- a/apps/web/src/components/WorkspacePanels.tsx
+++ b/apps/web/src/components/WorkspacePanels.tsx
@@ -5,6 +5,25 @@ import type { SettlementClient } from '@oneshot/settlement-ui';
import { RecoverySurface, SettlementSurface } from './FrontendSurfaces.js';
import { maskIdentifier } from './workspace-copy.js';
+function shortHash(value: string): string {
+ return `${value.slice(0, 10)}…${value.slice(-8)}`;
+}
+
+function graphStatusLabel(
+ status: ActivityResponse['transactions'][number]['graph_status'],
+): string {
+ switch (status) {
+ case 'INDEXED_TRANSFER':
+ return 'Indexed transfer';
+ case 'NOT_INDEXED':
+ return 'Hash not indexed';
+ case 'NO_TRANSACTION_HASH':
+ return 'No transaction hash';
+ case 'UNAVAILABLE':
+ return 'Graph unavailable';
+ }
+}
+
export function PaymentProtectionPanel({
activity,
activityError,
@@ -21,6 +40,8 @@ export function PaymentProtectionPanel({
readonly onRefresh: () => void;
}) {
const observation = activity?.observation;
+ const transactions = activity?.transactions ?? [];
+ const transfers = activity?.transfers ?? [];
const count = (value: number | undefined): string =>
activity === null || value === undefined ? '—' : String(value);
@@ -67,6 +88,94 @@ export function PaymentProtectionPanel({
: 'No activity check has been requested.')}
+ {activity && (
+
+
+
+
SITE AUDIT TRAIL
+
Every payment request
+
+ GRAPH + LEDGER
+
+
+ One row is shown for every payment request created in this workspace, including
+ rejected, failed, uncertain and approved outcomes.
+
+ {transactions.length > 0 ? (
+
+ {transactions.map((transaction) => (
+ -
+
+ {transaction.payment_state}
+ {transaction.payment_mode}
+
+
+ Request {maskIdentifier(transaction.business_intent_id)} ·{' '}
+ {transaction.amount_atomic} atomic USDC to{' '}
+ {shortHash(transaction.recipient)}
+
+
+ {transaction.transaction_hash ? (
+ <>
+ Transaction {shortHash(transaction.transaction_hash)}
+ >
+ ) : (
+ 'No transaction hash was recorded for this outcome.'
+ )}{' '}
+ · The Graph: {graphStatusLabel(transaction.graph_status)}
+ {transaction.graph_block_number
+ ? ` · block ${transaction.graph_block_number}`
+ : ''}
+ {transaction.graph_log_index !== undefined
+ ? ` · log ${transaction.graph_log_index}`
+ : ''}
+
+ {transaction.graph_status === 'NOT_INDEXED' && (
+
+ The Graph has no matching event yet. That is not proof that payment did not
+ happen; Arc receipt and OneShot state remain authoritative.
+
+ )}
+ {transaction.graph_status === 'UNAVAILABLE' && (
+
+ Graph evidence could not be read for this refresh. Arc receipt and OneShot
+ state remain authoritative.
+
+ )}
+
+ ))}
+
+ ) : (
+
No site payment requests are recorded yet.
+ )}
+ {transfers.length > 0 && (
+
+ Indexed Graph transfers ({transfers.length})
+
+ {transfers.map((transfer) => (
+ -
+
+
{shortHash(transfer.transaction_hash)} · log {transfer.log_index}{' '}
+ · {transfer.amount_atomic} atomic USDC
+
+
+ {transfer.match === 'RECORDED_SETTLEMENT'
+ ? 'Matched to a OneShot settlement'
+ : 'Unmatched network activity'}
+ {transfer.sender ? ` · from ${shortHash(transfer.sender)}` : ''}
+ {transfer.token_contract
+ ? ` · token ${shortHash(transfer.token_contract)}`
+ : ''}
+ {transfer.block_number ? ` · block ${transfer.block_number}` : ''}
+ {transfer.block_timestamp ? ` · ${transfer.block_timestamp}` : ''}
+
+
+ ))}
+
+
+ )}
+
+ )}
{intentId ? (
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 8aeae32..f88a414 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1828,6 +1828,112 @@ a.secondary:hover {
margin: 0;
}
+.proof-history {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--os-panel-line);
+}
+
+.proof-history-heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.proof-history-heading .eyebrow {
+ margin-bottom: 0.35rem;
+}
+
+.proof-history-heading h3 {
+ margin: 0;
+ color: var(--os-panel-ink);
+ font-size: 1.15rem;
+ font-weight: 500;
+}
+
+.proof-history .field-help {
+ margin: 0;
+}
+
+.proof-history-list,
+.proof-transfer-list {
+ display: grid;
+ gap: 0.65rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.proof-history-item,
+.proof-transfer-details {
+ padding: 0.9rem 1rem;
+ border: 1px solid var(--os-panel-line);
+ border-radius: 0.75rem;
+ background: var(--os-surface);
+}
+
+.proof-history-item {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.proof-history-item-heading {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ color: var(--os-ink);
+}
+
+.proof-history-item-heading span {
+ color: var(--os-ink-muted);
+ font-size: 0.75rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.proof-history-item p,
+.proof-history-item small,
+.proof-transfer-list small {
+ margin: 0;
+ color: var(--os-ink-muted);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.proof-history-item code,
+.proof-transfer-list code {
+ font-family: var(--os-font-mono);
+ font-size: 0.78rem;
+}
+
+.proof-history-item strong {
+ color: var(--os-ink);
+}
+
+.proof-transfer-details {
+ color: var(--os-ink);
+}
+
+.proof-transfer-details summary {
+ cursor: pointer;
+ font-size: 0.82rem;
+ font-weight: 500;
+}
+
+.proof-transfer-list {
+ margin-top: 0.75rem;
+}
+
+.proof-transfer-list li {
+ display: grid;
+ gap: 0.2rem;
+ padding-top: 0.65rem;
+ border-top: 1px solid var(--os-panel-line);
+}
+
.proof-request {
display: grid;
gap: 0.9rem;
diff --git a/apps/web/test/app-composition.test.tsx b/apps/web/test/app-composition.test.tsx
index 7b3933d..319daed 100644
--- a/apps/web/test/app-composition.test.tsx
+++ b/apps/web/test/app-composition.test.tsx
@@ -165,6 +165,7 @@ describe('Gate P5 shell composition', () => {
recorded_settlement_count: 0,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
};
},
@@ -198,6 +199,48 @@ describe('Gate P5 shell composition', () => {
expect(screen.getByText(/Open Payment services to start/u)).toBeTruthy();
});
+ it('shows Graph evidence beside every site payment outcome', async () => {
+ const hash = `0x${'a'.repeat(64)}`;
+ const jobClient = {
+ async refreshActivity() {
+ return {
+ observation: { freshness: 'FRESH' },
+ recorded_settlement_count: 0,
+ uncertain_job_count: 0,
+ unmatched_transfer_count: 0,
+ transactions: [
+ {
+ job_id: 'job-graph-evidence',
+ business_intent_id: 'intent-graph-evidence',
+ payment_state: 'FAILED_SAFE' as const,
+ payment_mode: 'USER_WALLET' as const,
+ transaction_hash: hash,
+ recipient: '0x1111111111111111111111111111111111111111',
+ amount_atomic: '1000000',
+ graph_status: 'NOT_INDEXED' as const,
+ },
+ ],
+ transfers: [],
+ };
+ },
+ } as unknown as JobApiClient;
+
+ render(
+ signedInSession()}
+ jobClient={jobClient}
+ settlementClient={createInMemorySettlementClient(SETTLEMENT_SCENARIO_INTENTS)}
+ recoveryClient={createInMemoryRecoveryClient('lagging')}
+ />,
+ );
+
+ await userEvent.setup().click(screen.getByRole('tab', { name: 'Payment proof' }));
+ expect(await screen.findByText('FAILED_SAFE')).toBeTruthy();
+ expect(screen.getByText('Hash not indexed')).toBeTruthy();
+ expect(screen.getByText(/not proof that payment did not happen/u)).toBeTruthy();
+ });
+
/**
* The tab strip faded on its own while the panel behind it appeared
* instantly: the console panel was never wrapped, and the request list
@@ -229,6 +272,7 @@ describe('Gate P5 shell composition', () => {
recorded_settlement_count: 0,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
};
},
diff --git a/apps/web/test/gate-p5.spec.ts b/apps/web/test/gate-p5.spec.ts
index 769aa4b..4c28cc9 100644
--- a/apps/web/test/gate-p5.spec.ts
+++ b/apps/web/test/gate-p5.spec.ts
@@ -24,6 +24,7 @@ test('cabinet activity refresh is authenticated, read-only, and does not persist
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
});
}
@@ -32,6 +33,7 @@ test('cabinet activity refresh is authenticated, read-only, and does not persist
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
+ transactions: [],
transfers: [],
});
}
diff --git a/apps/worker/src/recovery-bridge.ts b/apps/worker/src/recovery-bridge.ts
index 4260fc4..7336f23 100644
--- a/apps/worker/src/recovery-bridge.ts
+++ b/apps/worker/src/recovery-bridge.ts
@@ -155,7 +155,7 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor
}
/**
- * Captures non-authoritative Graph evidence for a confirmed settlement. This
+ * Captures non-authoritative Graph evidence for a site payment outcome. This
* port never reads or writes settlement authority; it only produces a bounded
* observation for the durable evidence timeline.
*/
diff --git a/apps/worker/src/types.ts b/apps/worker/src/types.ts
index 5350842..bc9688d 100644
--- a/apps/worker/src/types.ts
+++ b/apps/worker/src/types.ts
@@ -37,8 +37,8 @@ export interface SettlementPort {
export interface GraphEvidenceCaptureRequest {
readonly businessIntentId: string;
- readonly transactionHash: string;
- readonly blockNumber: string;
+ readonly transactionHash?: string;
+ readonly blockNumber?: string;
}
export interface GraphEvidenceCapturePort {
diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts
index b68a282..f1b54e0 100644
--- a/apps/worker/src/worker.ts
+++ b/apps/worker/src/worker.ts
@@ -17,17 +17,21 @@ function graphEvidenceRequest(
throw new Error('Graph evidence task payload is invalid');
}
const record = payload as Record;
- if (
- record.business_intent_id !== businessIntentId ||
- typeof record.transaction_hash !== 'string' ||
- typeof record.block_number !== 'string'
- ) {
+ if (record.business_intent_id !== businessIntentId) {
throw new Error('Graph evidence task payload does not match the outbox identity');
}
+ if (record.transaction_hash !== undefined && typeof record.transaction_hash !== 'string') {
+ throw new Error('Graph evidence task transaction hash is invalid');
+ }
+ if (record.block_number !== undefined && typeof record.block_number !== 'string') {
+ throw new Error('Graph evidence task block number is invalid');
+ }
return {
businessIntentId,
- transactionHash: asTransactionHash(record.transaction_hash),
- blockNumber: asBlockNumber(record.block_number),
+ ...(record.transaction_hash
+ ? { transactionHash: asTransactionHash(record.transaction_hash) }
+ : {}),
+ ...(record.block_number ? { blockNumber: asBlockNumber(record.block_number) } : {}),
};
}
diff --git a/apps/worker/test/worker.test.ts b/apps/worker/test/worker.test.ts
index a97a865..2117e26 100644
--- a/apps/worker/test/worker.test.ts
+++ b/apps/worker/test/worker.test.ts
@@ -196,6 +196,35 @@ describe('Worker Unit Logic', () => {
]);
});
+ it('captures Graph evidence for an outcome with no transaction hash', async () => {
+ let captured: { businessIntentId: string; transactionHash?: string } | undefined;
+ const tasks = createTaskList({
+ pool: {} as never,
+ ledger: createMockLedger({
+ async appendEvidence() {},
+ }),
+ settlementPort: {} as never,
+ graphEvidence: {
+ async capture(request) {
+ captured = request;
+ return {
+ source: 'THE_GRAPH',
+ authority_class: 'OBSERVATION',
+ retrieved_at: '2026-09-13T10:00:00.000Z',
+ digest: 'graph-no-hash',
+ freshness: 'UNAVAILABLE',
+ };
+ },
+ },
+ });
+
+ await tasks.capture_graph_evidence({
+ business_intent_id: sampleRequest.business_intent_id,
+ });
+
+ expect(captured).toEqual({ businessIntentId: sampleRequest.business_intent_id });
+ });
+
it('passes provider request identity into the atomic claim before calling the settlement port', async () => {
const order: string[] = [];
let claimedIdentity: unknown;
diff --git a/packages/contracts/generated/contracts.schema.json b/packages/contracts/generated/contracts.schema.json
index 7b44475..e841b55 100644
--- a/packages/contracts/generated/contracts.schema.json
+++ b/packages/contracts/generated/contracts.schema.json
@@ -743,6 +743,37 @@
"type": "integer",
"minimum": 0
},
+ "sender": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "token_contract": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "block_number": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "block_timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "network": {
+ "type": "string",
+ "const": "eip155:5042002"
+ },
"recipient": {
"type": "string",
"pattern": "^0x[0-9a-fA-F]{40}$",
@@ -774,6 +805,94 @@
}
}
},
+ "ActivityTransaction": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "job_id",
+ "business_intent_id",
+ "payment_state",
+ "payment_mode",
+ "recipient",
+ "amount_atomic",
+ "graph_status"
+ ],
+ "properties": {
+ "job_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$"
+ },
+ "business_intent_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$"
+ },
+ "payment_state": {
+ "type": "string",
+ "enum": [
+ "AUTHORIZING",
+ "READY",
+ "SUBMITTING",
+ "COMMITTED",
+ "FAILED_SAFE",
+ "UNKNOWN",
+ "REJECTED"
+ ]
+ },
+ "payment_mode": {
+ "type": "string",
+ "enum": [
+ "SERVER_PRIVY",
+ "USER_WALLET"
+ ]
+ },
+ "transaction_hash": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{64}$"
+ },
+ "recipient": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "amount_atomic": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "graph_status": {
+ "type": "string",
+ "enum": [
+ "INDEXED_TRANSFER",
+ "NOT_INDEXED",
+ "NO_TRANSACTION_HASH",
+ "UNAVAILABLE"
+ ]
+ },
+ "graph_block_number": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "graph_log_index": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
"ActivityResponse": {
"type": "object",
"additionalProperties": false,
@@ -781,6 +900,7 @@
"recorded_settlement_count",
"uncertain_job_count",
"unmatched_transfer_count",
+ "transactions",
"transfers"
],
"properties": {
@@ -796,6 +916,13 @@
"type": "integer",
"minimum": 0
},
+ "transactions": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "$ref": "#/$defs/ActivityTransaction"
+ }
+ },
"transfers": {
"type": "array",
"maxItems": 100,
diff --git a/packages/contracts/openapi/openapi.v1.json b/packages/contracts/openapi/openapi.v1.json
index 5983293..be2eae9 100644
--- a/packages/contracts/openapi/openapi.v1.json
+++ b/packages/contracts/openapi/openapi.v1.json
@@ -1854,6 +1854,37 @@
"type": "integer",
"minimum": 0
},
+ "sender": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "token_contract": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "block_number": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "block_timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "network": {
+ "type": "string",
+ "const": "eip155:5042002"
+ },
"recipient": {
"type": "string",
"pattern": "^0x[0-9a-fA-F]{40}$",
@@ -1885,6 +1916,94 @@
}
}
},
+ "ActivityTransaction": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "job_id",
+ "business_intent_id",
+ "payment_state",
+ "payment_mode",
+ "recipient",
+ "amount_atomic",
+ "graph_status"
+ ],
+ "properties": {
+ "job_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$"
+ },
+ "business_intent_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$"
+ },
+ "payment_state": {
+ "type": "string",
+ "enum": [
+ "AUTHORIZING",
+ "READY",
+ "SUBMITTING",
+ "COMMITTED",
+ "FAILED_SAFE",
+ "UNKNOWN",
+ "REJECTED"
+ ]
+ },
+ "payment_mode": {
+ "type": "string",
+ "enum": [
+ "SERVER_PRIVY",
+ "USER_WALLET"
+ ]
+ },
+ "transaction_hash": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{64}$"
+ },
+ "recipient": {
+ "type": "string",
+ "pattern": "^0x[0-9a-fA-F]{40}$",
+ "examples": [
+ "0x1111111111111111111111111111111111111111"
+ ]
+ },
+ "amount_atomic": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "graph_status": {
+ "type": "string",
+ "enum": [
+ "INDEXED_TRANSFER",
+ "NOT_INDEXED",
+ "NO_TRANSACTION_HASH",
+ "UNAVAILABLE"
+ ]
+ },
+ "graph_block_number": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 78,
+ "pattern": "^(0|[1-9][0-9]*)$",
+ "examples": [
+ "1250000"
+ ]
+ },
+ "graph_log_index": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
"ActivityResponse": {
"type": "object",
"additionalProperties": false,
@@ -1892,6 +2011,7 @@
"recorded_settlement_count",
"uncertain_job_count",
"unmatched_transfer_count",
+ "transactions",
"transfers"
],
"properties": {
@@ -1907,6 +2027,13 @@
"type": "integer",
"minimum": 0
},
+ "transactions": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "$ref": "#/components/schemas/ActivityTransaction"
+ }
+ },
"transfers": {
"type": "array",
"maxItems": 100,
diff --git a/packages/contracts/scripts/generate-contracts.mjs b/packages/contracts/scripts/generate-contracts.mjs
index 27935b9..a2a1469 100644
--- a/packages/contracts/scripts/generate-contracts.mjs
+++ b/packages/contracts/scripts/generate-contracts.mjs
@@ -306,12 +306,45 @@ const schemas = {
properties: {
transaction_hash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' },
log_index: { type: 'integer', minimum: 0 },
+ sender: evmAddress,
+ token_contract: evmAddress,
+ block_number: amountAtomic,
+ block_timestamp: { type: 'string', format: 'date-time' },
+ network: { type: 'string', const: 'eip155:5042002' },
recipient: evmAddress,
amount_atomic: amountAtomic,
match: { type: 'string', enum: ['RECORDED_SETTLEMENT', 'UNMATCHED'] },
job_id: boundedId,
},
},
+ ActivityTransaction: {
+ type: 'object',
+ additionalProperties: false,
+ required: [
+ 'job_id',
+ 'business_intent_id',
+ 'payment_state',
+ 'payment_mode',
+ 'recipient',
+ 'amount_atomic',
+ 'graph_status',
+ ],
+ properties: {
+ job_id: boundedId,
+ business_intent_id: boundedId,
+ payment_state: { type: 'string', enum: intentStates },
+ payment_mode: { type: 'string', enum: paymentModes },
+ transaction_hash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' },
+ recipient: evmAddress,
+ amount_atomic: amountAtomic,
+ graph_status: {
+ type: 'string',
+ enum: ['INDEXED_TRANSFER', 'NOT_INDEXED', 'NO_TRANSACTION_HASH', 'UNAVAILABLE'],
+ },
+ graph_block_number: amountAtomic,
+ graph_log_index: { type: 'integer', minimum: 0 },
+ },
+ },
ActivityResponse: {
type: 'object',
additionalProperties: false,
@@ -319,12 +352,18 @@ const schemas = {
'recorded_settlement_count',
'uncertain_job_count',
'unmatched_transfer_count',
+ 'transactions',
'transfers',
],
properties: {
recorded_settlement_count: { type: 'integer', minimum: 0 },
uncertain_job_count: { type: 'integer', minimum: 0 },
unmatched_transfer_count: { type: 'integer', minimum: 0 },
+ transactions: {
+ type: 'array',
+ maxItems: 100,
+ items: { $ref: '#/$defs/ActivityTransaction' },
+ },
transfers: { type: 'array', maxItems: 100, items: { $ref: '#/$defs/ActivityTransfer' } },
observation: { type: 'object', additionalProperties: true },
},
@@ -910,17 +949,36 @@ export interface JobListResponse {
export interface ActivityTransferView {
readonly transaction_hash: string;
readonly log_index: number;
+ readonly sender?: string;
+ readonly token_contract?: string;
+ readonly block_number?: string;
+ readonly block_timestamp?: string;
+ readonly network?: 'eip155:5042002';
readonly recipient: string;
readonly amount_atomic: string;
readonly match: 'RECORDED_SETTLEMENT' | 'UNMATCHED';
readonly job_id?: string;
}
+export interface ActivityTransactionView {
+ readonly job_id: string;
+ readonly business_intent_id: string;
+ readonly payment_state: IntentState;
+ readonly payment_mode: PaymentMode;
+ readonly transaction_hash?: string;
+ readonly recipient: string;
+ readonly amount_atomic: string;
+ readonly graph_status: 'INDEXED_TRANSFER' | 'NOT_INDEXED' | 'NO_TRANSACTION_HASH' | 'UNAVAILABLE';
+ readonly graph_block_number?: string;
+ readonly graph_log_index?: number;
+}
+
export interface ActivityResponse {
readonly observation?: Record;
readonly recorded_settlement_count: number;
readonly uncertain_job_count: number;
readonly unmatched_transfer_count: number;
+ readonly transactions: readonly ActivityTransactionView[];
readonly transfers: readonly ActivityTransferView[];
}
diff --git a/packages/contracts/src/generated/api-types.ts b/packages/contracts/src/generated/api-types.ts
index 74fe53e..92c7114 100644
--- a/packages/contracts/src/generated/api-types.ts
+++ b/packages/contracts/src/generated/api-types.ts
@@ -147,17 +147,36 @@ export interface JobListResponse {
export interface ActivityTransferView {
readonly transaction_hash: string;
readonly log_index: number;
+ readonly sender?: string;
+ readonly token_contract?: string;
+ readonly block_number?: string;
+ readonly block_timestamp?: string;
+ readonly network?: 'eip155:5042002';
readonly recipient: string;
readonly amount_atomic: string;
readonly match: 'RECORDED_SETTLEMENT' | 'UNMATCHED';
readonly job_id?: string;
}
+export interface ActivityTransactionView {
+ readonly job_id: string;
+ readonly business_intent_id: string;
+ readonly payment_state: IntentState;
+ readonly payment_mode: PaymentMode;
+ readonly transaction_hash?: string;
+ readonly recipient: string;
+ readonly amount_atomic: string;
+ readonly graph_status: 'INDEXED_TRANSFER' | 'NOT_INDEXED' | 'NO_TRANSACTION_HASH' | 'UNAVAILABLE';
+ readonly graph_block_number?: string;
+ readonly graph_log_index?: number;
+}
+
export interface ActivityResponse {
readonly observation?: Record;
readonly recorded_settlement_count: number;
readonly uncertain_job_count: number;
readonly unmatched_transfer_count: number;
+ readonly transactions: readonly ActivityTransactionView[];
readonly transfers: readonly ActivityTransferView[];
}
diff --git a/packages/storage-postgres/src/jobs.ts b/packages/storage-postgres/src/jobs.ts
index 824d4a2..7e7fa55 100644
--- a/packages/storage-postgres/src/jobs.ts
+++ b/packages/storage-postgres/src/jobs.ts
@@ -4,6 +4,7 @@ import {
asEvmAddress,
asTransactionHash,
type ActivityResponse,
+ type ActivityTransactionView,
type ActivityTransferView,
parseCreateJobRequest,
parseCreateUserWalletJobRequest,
@@ -128,6 +129,11 @@ function asView(row: JobRow): JobView {
interface ActivityTransferInput {
readonly transaction_hash: string;
readonly log_index: number;
+ readonly sender?: string;
+ readonly token_contract?: string;
+ readonly block_number?: string;
+ readonly block_timestamp?: string;
+ readonly network?: 'eip155:5042002';
readonly recipient: string;
readonly amount_atomic: string;
}
@@ -153,6 +159,31 @@ function parseActivityTransfers(payload: unknown): readonly ActivityTransferInpu
return {
transaction_hash: asTransactionHash(row.transaction_hash),
log_index: logIndex,
+ ...(row.sender === undefined ? {} : { sender: asEvmAddress(row.sender) }),
+ ...(row.token_contract === undefined
+ ? {}
+ : { token_contract: asEvmAddress(row.token_contract) }),
+ ...(row.block_number === undefined
+ ? {}
+ : { block_number: asAtomicAmount(row.block_number) }),
+ ...(row.block_timestamp === undefined
+ ? {}
+ : {
+ block_timestamp:
+ typeof row.block_timestamp === 'string' &&
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/u.test(row.block_timestamp)
+ ? row.block_timestamp
+ : (() => {
+ throw new Error('invalid block timestamp');
+ })(),
+ }),
+ ...(row.network === undefined
+ ? {}
+ : row.network === 'eip155:5042002'
+ ? { network: row.network }
+ : (() => {
+ throw new Error('invalid network');
+ })()),
recipient: asEvmAddress(row.recipient),
amount_atomic: asAtomicAmount(row.amount_atomic),
};
@@ -480,6 +511,17 @@ export class JobLedger {
);
}
+ async activityPayerWallets(workspaceId: string): Promise {
+ const result = await this.#pool.query<{ payer_wallet: string }>(
+ `SELECT DISTINCT lower(payer_wallet) AS payer_wallet
+ FROM resumable_jobs
+ WHERE workspace_id = $1 AND payer_wallet IS NOT NULL
+ ORDER BY payer_wallet ASC`,
+ [workspaceId],
+ );
+ return result.rows.map((row) => asEvmAddress(row.payer_wallet));
+ }
+
async recordActivityObservation(params: {
readonly workspaceId: string;
readonly freshness: 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS';
@@ -502,45 +544,76 @@ export class JobLedger {
}
async activity(workspaceId: string): Promise {
- const [observation, settlements, uncertain, recordedTransfers] = await Promise.all([
- this.#pool.query<{
- freshness: string;
- coverage_note: string;
- observed_at: Date;
- payload: unknown;
- }>(
- `SELECT freshness, coverage_note, observed_at, payload FROM wallet_activity_observations
+ const [observation, settlements, uncertain, recordedTransfers, activityJobs] =
+ await Promise.all([
+ this.#pool.query<{
+ freshness: string;
+ coverage_note: string;
+ observed_at: Date;
+ payload: unknown;
+ }>(
+ `SELECT freshness, coverage_note, observed_at, payload FROM wallet_activity_observations
WHERE workspace_id = $1 ORDER BY observation_id DESC LIMIT 1`,
- [workspaceId],
- ),
- this.#pool.query<{ count: string }>(
- `SELECT count(*)::text AS count FROM (
+ [workspaceId],
+ ),
+ this.#pool.query<{ count: string }>(
+ `SELECT count(*)::text AS count FROM (
SELECT j.business_intent_id FROM resumable_jobs j
JOIN settlements s ON s.business_intent_id = j.business_intent_id
WHERE j.workspace_id = $1
) recorded`,
- [workspaceId],
- ),
- this.#pool.query<{ count: string }>(
- `SELECT count(*)::text AS count FROM (
+ [workspaceId],
+ ),
+ this.#pool.query<{ count: string }>(
+ `SELECT count(*)::text AS count FROM (
SELECT j.business_intent_id FROM resumable_jobs j
JOIN business_intents i ON i.business_intent_id = j.business_intent_id
WHERE j.workspace_id = $1 AND i.state = 'UNKNOWN'
) uncertain`,
- [workspaceId],
- ),
- this.#pool.query<{
- transaction_hash: string;
- transfer_log_index: number;
- job_id: string;
- }>(
- `SELECT s.transaction_hash, s.transfer_log_index, j.job_id
+ [workspaceId],
+ ),
+ this.#pool.query<{
+ transaction_hash: string;
+ transfer_log_index: number;
+ job_id: string;
+ }>(
+ `SELECT s.transaction_hash, s.transfer_log_index, j.job_id
FROM settlements s
JOIN resumable_jobs j ON j.business_intent_id = s.business_intent_id
WHERE j.workspace_id = $1`,
- [workspaceId],
- ),
- ]);
+ [workspaceId],
+ ),
+ this.#pool.query<{
+ job_id: string;
+ business_intent_id: string;
+ payment_state: JobView['payment_state'];
+ payment_mode: PaymentMode;
+ transaction_hash: string | null;
+ recipient: string;
+ amount_atomic: string;
+ transfer_log_index: number | null;
+ }>(
+ `SELECT j.job_id, j.business_intent_id, i.state AS payment_state, j.payment_mode,
+ COALESCE(s.transaction_hash, j.payment_transaction_hash, latest.provider_transaction_hash) AS transaction_hash,
+ j.supplier_quote->>'recipient' AS recipient,
+ j.supplier_quote->>'amount_atomic' AS amount_atomic,
+ s.transfer_log_index
+ FROM resumable_jobs j
+ JOIN business_intents i ON i.business_intent_id = j.business_intent_id
+ LEFT JOIN settlements s ON s.business_intent_id = j.business_intent_id
+ LEFT JOIN LATERAL (
+ SELECT a.provider_transaction_hash
+ FROM attempts a
+ WHERE a.business_intent_id = j.business_intent_id
+ ORDER BY a.attempt_sequence DESC
+ LIMIT 1
+ ) latest ON true
+ WHERE j.workspace_id = $1
+ ORDER BY j.updated_at DESC, j.job_id ASC
+ LIMIT 100`,
+ [workspaceId],
+ ),
+ ]);
const row = observation.rows[0];
const indexedTransfers = row ? parseActivityTransfers(row.payload) : [];
const recordedByTransfer = new Map(
@@ -559,12 +632,57 @@ export class JobLedger {
...(jobId ? { job_id: jobId } : {}),
};
});
+ const transactions: readonly ActivityTransactionView[] = activityJobs.rows.map((job) => {
+ const transactionHash = job.transaction_hash
+ ? asTransactionHash(job.transaction_hash)
+ : undefined;
+ const recipient = asEvmAddress(job.recipient);
+ const amountAtomic = asAtomicAmount(job.amount_atomic);
+ const matchesPaymentTuple = (transfer: ActivityTransferInput): boolean =>
+ transfer.transaction_hash.toLowerCase() === transactionHash?.toLowerCase() &&
+ transfer.recipient.toLowerCase() === recipient.toLowerCase() &&
+ transfer.amount_atomic === amountAtomic &&
+ (transfer.token_contract === undefined ||
+ transfer.token_contract.toLowerCase() === '0x3600000000000000000000000000000000000000');
+ const graphTransfer = transactionHash
+ ? indexedTransfers.find(matchesPaymentTuple)
+ : undefined;
+ const exactTransfer =
+ transactionHash && job.transfer_log_index !== null
+ ? indexedTransfers.find(
+ (transfer) =>
+ transfer.log_index === job.transfer_log_index && matchesPaymentTuple(transfer),
+ )
+ : undefined;
+ const matchedTransfer = exactTransfer ?? graphTransfer;
+ return {
+ job_id: job.job_id,
+ business_intent_id: job.business_intent_id,
+ payment_state: job.payment_state,
+ payment_mode: job.payment_mode,
+ ...(transactionHash ? { transaction_hash: transactionHash } : {}),
+ recipient,
+ amount_atomic: amountAtomic,
+ graph_status: transactionHash
+ ? !row || row.freshness === 'UNAVAILABLE'
+ ? 'UNAVAILABLE'
+ : matchedTransfer
+ ? 'INDEXED_TRANSFER'
+ : 'NOT_INDEXED'
+ : 'NO_TRANSACTION_HASH',
+ ...(matchedTransfer?.block_number
+ ? { graph_block_number: matchedTransfer.block_number }
+ : {}),
+ ...(matchedTransfer ? { graph_log_index: matchedTransfer.log_index } : {}),
+ };
+ });
return {
...(row ? { observation: { ...row, observed_at: row.observed_at.toISOString() } } : {}),
recorded_settlement_count: Number(settlements.rows[0]?.count ?? '0'),
uncertain_job_count: Number(uncertain.rows[0]?.count ?? '0'),
unmatched_transfer_count: transfers.filter((transfer) => transfer.match === 'UNMATCHED')
.length,
+ transactions,
transfers,
};
}
diff --git a/packages/storage-postgres/src/ledger.ts b/packages/storage-postgres/src/ledger.ts
index 88e3d70..a467520 100644
--- a/packages/storage-postgres/src/ledger.ts
+++ b/packages/storage-postgres/src/ledger.ts
@@ -396,6 +396,30 @@ export class IntentLedger {
}
}
+ async #enqueueGraphEvidenceOnClient(
+ client: PoolClient,
+ businessIntentId: BusinessIntentId,
+ version: number,
+ values: { readonly transactionHash?: string; readonly blockNumber?: string } = {},
+ ): Promise {
+ await client.query(
+ `INSERT INTO outbox_jobs (
+ business_intent_id, job_key, task_identifier, payload, available_at, created_at
+ ) VALUES ($1, $2, 'capture_graph_evidence', $3::jsonb, $4, $4)
+ ON CONFLICT (job_key) DO NOTHING`,
+ [
+ businessIntentId,
+ `graph-evidence:${businessIntentId}:${version}`,
+ JSON.stringify({
+ business_intent_id: businessIntentId,
+ ...(values.transactionHash ? { transaction_hash: values.transactionHash } : {}),
+ ...(values.blockNumber ? { block_number: values.blockNumber } : {}),
+ }),
+ this.#dependencies.now(),
+ ],
+ );
+ }
+
async ping(): Promise {
await this.#pool.query('SELECT 1');
}
@@ -663,6 +687,7 @@ export class IntentLedger {
['REJECTED', result.reason, id],
);
await this.#recordMetricEventOnClient(client, id, 'POLICY_DENIAL', 'AUTHORIZATION');
+ await this.#enqueueGraphEvidenceOnClient(client, id, newVersion);
await client.query('COMMIT');
return { completed: true, state: 'REJECTED', version: newVersion };
}
@@ -1071,6 +1096,7 @@ export class IntentLedger {
"UPDATE attempts SET stage = 'UNKNOWN', sanitized_error = $1 WHERE attempt_id = $2",
[reason.slice(0, 256), attemptId],
);
+ await this.#enqueueGraphEvidenceOnClient(client, id, newVersion);
await this.#recordMetricEventOnClient(client, id, 'PROVIDER_ERROR', 'USER_WALLET_UNKNOWN');
await client.query('COMMIT');
return { completed: true, state: 'UNKNOWN', version: newVersion };
@@ -1180,22 +1206,10 @@ export class IntentLedger {
],
);
}
- await client.query(
- `INSERT INTO outbox_jobs (
- business_intent_id, job_key, task_identifier, payload, available_at, created_at
- ) VALUES ($1, $2, 'capture_graph_evidence', $3::jsonb, $4, $4)
- ON CONFLICT (job_key) DO NOTHING`,
- [
- id,
- `graph-evidence:${id}:${newVersion}`,
- JSON.stringify({
- business_intent_id: id,
- transaction_hash: result.transaction_hash,
- block_number: result.block_number,
- }),
- now,
- ],
- );
+ await this.#enqueueGraphEvidenceOnClient(client, id, newVersion, {
+ transactionHash: result.transaction_hash,
+ blockNumber: result.block_number,
+ });
await client.query('COMMIT');
return { completed: true, state: 'COMMITTED', version: newVersion };
}
@@ -1213,6 +1227,7 @@ export class IntentLedger {
'UPDATE attempts SET stage = $1, sanitized_error = $2 WHERE attempt_id = $3',
['FAILED_SAFE', result.reason, attemptId],
);
+ await this.#enqueueGraphEvidenceOnClient(client, id, newVersion);
await client.query('COMMIT');
return { completed: true, state: 'FAILED_SAFE', version: newVersion };
}
@@ -1240,6 +1255,7 @@ export class IntentLedger {
ON CONFLICT (job_key) DO NOTHING`,
[id, `reconcile:${id}:${newVersion}`, JSON.stringify({ business_intent_id: id }), now],
);
+ await this.#enqueueGraphEvidenceOnClient(client, id, newVersion);
await this.#recordMetricEventOnClient(client, id, 'PROVIDER_ERROR', 'POSSIBLY_SUBMITTED');
await client.query('COMMIT');
return { completed: true, state: 'UNKNOWN', version: newVersion };
@@ -1482,6 +1498,11 @@ export class IntentLedger {
now,
],
);
+ await this.#enqueueGraphEvidenceOnClient(
+ client,
+ asBusinessIntentId(orphan.business_intent_id),
+ newVersion,
+ );
recovered.push({ businessIntentId: orphan.business_intent_id, newVersion });
}
diff --git a/packages/storage-postgres/test/jobs.test.ts b/packages/storage-postgres/test/jobs.test.ts
index dc52438..089746f 100644
--- a/packages/storage-postgres/test/jobs.test.ts
+++ b/packages/storage-postgres/test/jobs.test.ts
@@ -119,6 +119,84 @@ describe('JobLedger delivery recovery', () => {
});
});
+ it('projects every site outcome with its Graph match status', async () => {
+ const indexedHash = `0x${'c'.repeat(64)}`;
+ const rejectedJob = {
+ job_id: 'job-rejected-site',
+ business_intent_id: 'intent-rejected-site',
+ payment_state: 'REJECTED' as const,
+ payment_mode: 'USER_WALLET' as const,
+ transaction_hash: null,
+ recipient: failedJob.supplier_quote.recipient,
+ amount_atomic: failedJob.supplier_quote.amount_atomic,
+ transfer_log_index: null,
+ };
+ const failedJobActivity = {
+ job_id: 'job-failed-site',
+ business_intent_id: 'intent-failed-site',
+ payment_state: 'FAILED_SAFE' as const,
+ payment_mode: 'USER_WALLET' as const,
+ transaction_hash: indexedHash,
+ recipient: failedJob.supplier_quote.recipient,
+ amount_atomic: failedJob.supplier_quote.amount_atomic,
+ transfer_log_index: null,
+ };
+ const pool = {
+ async query(sql: string) {
+ if (sql.includes('FROM wallet_activity_observations')) {
+ return {
+ rows: [
+ {
+ freshness: 'FRESH',
+ coverage_note: 'indexed',
+ observed_at: new Date('2026-09-07T12:00:00.000Z'),
+ payload: {
+ transfers: [
+ {
+ transaction_hash: indexedHash,
+ log_index: 7,
+ recipient: failedJob.supplier_quote.recipient,
+ amount_atomic: failedJob.supplier_quote.amount_atomic,
+ block_number: '123',
+ },
+ ],
+ },
+ },
+ ],
+ };
+ }
+ if (sql.includes('FROM settlements s')) return { rows: [] };
+ if (sql.includes("i.state = 'UNKNOWN'")) return { rows: [{ count: '0' }] };
+ if (sql.includes('SELECT j.job_id, j.business_intent_id')) {
+ return { rows: [failedJobActivity, rejectedJob] };
+ }
+ if (sql.includes('recorded')) return { rows: [{ count: '0' }] };
+ return { rows: [] };
+ },
+ };
+ const ledger = new JobLedger(pool as never, {
+ now: () => new Date('2026-09-07T12:01:00.000Z'),
+ nextAttemptId: () => 'unused',
+ });
+
+ await expect(ledger.activity('workspace-unit')).resolves.toMatchObject({
+ transactions: [
+ {
+ business_intent_id: 'intent-failed-site',
+ payment_state: 'FAILED_SAFE',
+ graph_status: 'INDEXED_TRANSFER',
+ graph_block_number: '123',
+ graph_log_index: 7,
+ },
+ {
+ business_intent_id: 'intent-rejected-site',
+ payment_state: 'REJECTED',
+ graph_status: 'NO_TRANSACTION_HASH',
+ },
+ ],
+ });
+ });
+
it('projects committed settlement evidence with the Arc Testnet explorer link', async () => {
const settledJob = {
...failedJob,