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) => ( +
  1. +
    + {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. + + )} +
  2. + ))} +
+ ) : ( +

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,