Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .agent/context/20260913T-graph-transaction-evidence.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ ONESHOT_SUBGRAPH_QUERY_URL=https://api.studio.thegraph.com/query/<studio-id>/<su
# Configure these only for a supported Network/MCP profile.
# ONESHOT_SUBGRAPH_MCP_ENDPOINT=https://mcp.example.invalid
ONESHOT_GRAPH_API_KEY=<set-in-secret-store-not-here>
# 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/<studio-id>/<subgraph>/<version>
# 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=<subgraph-manifest-cid>
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
39 changes: 38 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ApiDependencies {
| 'list'
| 'resumeDelivery'
| 'recordActivityObservation'
| 'activityPayerWallets'
| 'activity'
>;
readonly supplier?: SupplierPort;
Expand Down Expand Up @@ -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<void> {
// 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,
Expand Down Expand Up @@ -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);
},
);
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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() }
: {}),
Expand Down
62 changes: 57 additions & 5 deletions apps/api/src/wallet-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,69 @@ 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;
}[];
};
}

export interface WalletActivityPort {
refresh(): Promise<WalletActivitySnapshot>;
refresh(wallets?: readonly string[]): Promise<WalletActivitySnapshot>;
}

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<WalletActivitySnapshot> {
async refresh(wallets: readonly string[] = []): Promise<WalletActivitySnapshot> {
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: {
Expand All @@ -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');
Expand Down Expand Up @@ -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,
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ describe('resumable job API boundary', () => {
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
transactions: [],
transfers: [],
};
},
Expand Down Expand Up @@ -677,6 +678,7 @@ describe('resumable job API boundary', () => {
recorded_settlement_count: 1,
uncertain_job_count: 0,
unmatched_transfer_count: 0,
transactions: [],
transfers: [],
});

Expand Down
10 changes: 10 additions & 0 deletions apps/api/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading