Skip to content

Commit 3799ca2

Browse files
authored
Merge pull request #139 from SWOFART/fix/graph-transaction-evidence
fix: surface Graph evidence for site payment outcomes
2 parents 68626d2 + a5beee8 commit 3799ca2

25 files changed

Lines changed: 1169 additions & 83 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Session Context: graph-transaction-evidence
2+
3+
## Date/time
4+
5+
- UTC: 2026-09-13
6+
7+
## User goal
8+
9+
Make The Graph call and display evidence for transactions performed through the
10+
site, regardless of whether the OneShot request is confirmed, failed safely, or
11+
remains uncertain.
12+
13+
## Original prompt/request
14+
15+
“Our the graph is never called and give any info on our transactions. Fix it so
16+
The Graph shows evidence for transactions performed via our site, whether the
17+
transaction failed or was approved.”
18+
19+
## Assumptions
20+
21+
- The Graph remains non-authoritative; OneShot and Arc receipt evidence decide
22+
settlement state.
23+
- A failed/rejected request may have no indexed ERC-20 Transfer event. The UI
24+
must show that state and say that missing Graph data is not proof of no
25+
payment.
26+
- User-wallet payer addresses must be discovered from durable workspace jobs;
27+
the configured server wallet remains an optional fallback for server-wallet
28+
activity.
29+
- The pre-existing edit to the prior session context remains user-owned.
30+
31+
## Plan
32+
33+
1. Make API Graph activity use all durable workspace payer wallets and refresh
34+
automatically from the cabinet.
35+
2. Return and render a workspace transaction ledger with Graph match status for
36+
every site request outcome.
37+
3. Enqueue durable Graph evidence capture for confirmed, failed-safe, unknown,
38+
and rejected lifecycle outcomes.
39+
4. Add focused API, storage, worker, and browser/UI regression coverage.
40+
41+
## Key decisions
42+
43+
- A missing indexed transfer is displayed as `NOT_INDEXED`, never as proof that
44+
a payment did not happen.
45+
- A failed Graph read is displayed as `UNAVAILABLE`, not as a negative payment
46+
result.
47+
- Failed-safe and rejected requests are represented in the site transaction
48+
ledger even when no transaction hash exists.
49+
- Graph transport failures remain observable as unavailable activity and do not
50+
change payment state or create retry permission.
51+
52+
## Branch state
53+
54+
- Branch: `fix/graph-transaction-evidence`
55+
- Base: refreshed `origin/develop` at `65200cc2dfcf22912e532a157232e439d623044f`.
56+
- Commit/PR: not created.
57+
- Gate A/B: not started.
58+
59+
## Checks
60+
61+
- Policy and routed idempotency/failure-injection documents read.
62+
- `pnpm --filter @oneshot/contracts check:generated` passed.
63+
- `pnpm lint`, `pnpm typecheck`, and `pnpm build` passed.
64+
- Focused API/storage/worker/web suites passed.
65+
- Full `pnpm test` passed: 80 files, 1,057 tests.
66+
- `pnpm test:browser` passed: 8 browser tests.
67+
- `pnpm test:integration` loaded all integration suites but skipped them because
68+
this workstation has no container runtime.
69+
- No commit, push, PR, deployment, or FreePi Gate A/B run has been performed
70+
yet; these are pending the explicit push/PR request.
71+
72+
## Unresolved questions
73+
74+
- The Graph indexes successful ERC-20 transfer events; reverted/no-transfer
75+
transactions cannot be fabricated into the subgraph. They will be shown with
76+
their OneShot outcome and explicit non-proof wording.
77+
78+
## Handoff/next steps
79+
80+
Stage the scoped tree, run Gate A, commit, push, open the draft PR, wait for
81+
required CI, and run Gate B before handing off for human review.

‎.env.example‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ ONESHOT_SUBGRAPH_QUERY_URL=https://api.studio.thegraph.com/query/<studio-id>/<su
5555
# Configure these only for a supported Network/MCP profile.
5656
# ONESHOT_SUBGRAPH_MCP_ENDPOINT=https://mcp.example.invalid
5757
ONESHOT_GRAPH_API_KEY=<set-in-secret-store-not-here>
58-
# Optional bounded manual wallet-activity refresh for the authenticated cabinet.
59-
# When unset, activity reports Graph as unavailable without affecting payments.
58+
# The API uses ONESHOT_SUBGRAPH_QUERY_URL automatically for the authenticated
59+
# cabinet. Keep this legacy variable only when the activity path needs a
60+
# different pinned deployment; it overrides the canonical URL.
6061
# ONESHOT_GRAPH_QUERY_URL=https://api.studio.thegraph.com/query/<studio-id>/<subgraph>/<version>
62+
# Optional server-wallet fallback for Graph activity. User-wallet payer
63+
# addresses are discovered from durable workspace jobs automatically.
64+
# ONESHOT_ACTIVITY_WALLET_ADDRESS=0x<40-hex-server-wallet-address>
6165
# ONESHOT_SUBGRAPH_MCP_SERVER_VERSION=1.0.0
6266
ONESHOT_SUBGRAPH_DEPLOYMENT_ID=0x<64-hex-deployment-id>
6367
ONESHOT_SUBGRAPH_MANIFEST_CID=<subgraph-manifest-cid>

‎README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,13 @@ frozen `recovery-view` API into the C05 timeline model, with labelled
173173
fail-closed fallbacks for legacy or unavailable evidence. The P5 browser
174174
acceptance suite runs with Playwright/Chromium in CI.
175175

176-
Authenticated wallet activity is read-only: the API records bounded Graph
177-
observations, links indexed transfers to settlements in the configured
178-
workspace, and surfaces unmatched transfers. Graph absence or lag never changes
179-
payment authority.
176+
Authenticated site activity is read-only: the API automatically queries the
177+
configured Arc subgraph for every payer wallet recorded in the workspace,
178+
records bounded Graph observations, and displays one audit row for every site
179+
payment request, including rejected, failed-safe, uncertain, and committed
180+
outcomes. Indexed transfers are linked to settlements and unmatched transfers
181+
remain visible. Graph absence or lag never changes payment authority; a missing
182+
or reverted transfer event is not proof that no payment happened.
180183

181184
Integration tests need a database:
182185

‎apps/api/src/app.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface ApiDependencies {
6161
| 'list'
6262
| 'resumeDelivery'
6363
| 'recordActivityObservation'
64+
| 'activityPayerWallets'
6465
| 'activity'
6566
>;
6667
readonly supplier?: SupplierPort;
@@ -143,6 +144,38 @@ export function buildApi(dependencies: ApiDependencies) {
143144
const workspaceFor = (request: FastifyRequest): string =>
144145
requestWorkspaces.get(request) ?? defaultWorkspaceId;
145146
const walletActivity = dependencies.walletActivity ?? new UnavailableWalletActivityPort();
147+
async function refreshGraphActivity(
148+
workspaceId: string,
149+
additionalWallet?: string,
150+
): Promise<void> {
151+
// A missing activity port is the deliberate local/test fallback. The
152+
// configured production port is queried after user-wallet outcomes as
153+
// well as from the cabinet refresh, so Graph is not recovery-only.
154+
if (!dependencies.walletActivity || !dependencies.jobs?.recordActivityObservation) return;
155+
try {
156+
const wallets = dependencies.jobs.activityPayerWallets
157+
? await dependencies.jobs.activityPayerWallets(workspaceId)
158+
: additionalWallet
159+
? [additionalWallet]
160+
: [];
161+
const normalizedAdditionalWallet = additionalWallet?.toLowerCase();
162+
const observation = await dependencies.walletActivity.refresh(
163+
normalizedAdditionalWallet &&
164+
!wallets.some((wallet) => wallet.toLowerCase() === normalizedAdditionalWallet)
165+
? [...wallets, normalizedAdditionalWallet]
166+
: wallets,
167+
);
168+
await dependencies.jobs.recordActivityObservation({
169+
workspaceId,
170+
freshness: observation.freshness,
171+
coverageNote: observation.coverageNote,
172+
payload: observation.payload,
173+
});
174+
} catch {
175+
// Activity is read-only evidence. A provider failure must not change the
176+
// payment response or turn a missing index row into a no-payment claim.
177+
}
178+
}
146179
const jobsUnavailable = (reply: FastifyReply, request: FastifyRequest): void =>
147180
sendError(
148181
reply,
@@ -621,6 +654,7 @@ export function buildApi(dependencies: ApiDependencies) {
621654
);
622655
return;
623656
}
657+
await refreshGraphActivity(workspaceFor(request), job.user_payment.payer_wallet);
624658
return reply.code(updated.payment_state === 'UNKNOWN' ? 202 : 200).send(updated);
625659
},
626660
);
@@ -686,7 +720,10 @@ export function buildApi(dependencies: ApiDependencies) {
686720
jobsUnavailable(reply, request);
687721
return;
688722
}
689-
const observation = await walletActivity.refresh();
723+
const wallets = dependencies.jobs.activityPayerWallets
724+
? await dependencies.jobs.activityPayerWallets(workspaceFor(request))
725+
: [];
726+
const observation = await walletActivity.refresh(wallets);
690727
await dependencies.jobs.recordActivityObservation({
691728
workspaceId: workspaceFor(request),
692729
freshness: observation.freshness,

‎apps/api/src/config.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export interface ApiRuntimeConfig {
2222
readonly privyAuth?: PrivyAuthRuntimeConfig;
2323
readonly walletActivity?: {
2424
readonly endpoint: string;
25-
readonly wallet: string;
25+
/** Optional server-wallet fallback; user-wallet payers come from the workspace ledger. */
26+
readonly wallet?: string;
2627
readonly apiKey?: string;
2728
};
2829
/** Credential-free read-only RPC used to verify user-submitted receipts. */
@@ -206,13 +207,18 @@ export function loadApiRuntimeConfig(
206207
): ApiRuntimeConfig {
207208
const workspaceId = environment.ONESHOT_WORKSPACE_ID?.trim() || 'default-workspace';
208209
const privyAuth = privyAuthConfig(environment);
209-
const activityEndpoint = environment.ONESHOT_GRAPH_QUERY_URL?.trim();
210+
// The worker and API must query the same pinned Studio deployment. Keep the
211+
// older activity-specific variable as an explicit override for deployments
212+
// that still use it, but make the worker's canonical subgraph URL sufficient
213+
// for the site activity path too.
214+
const activityEndpoint =
215+
environment.ONESHOT_GRAPH_QUERY_URL?.trim() || environment.ONESHOT_SUBGRAPH_QUERY_URL?.trim();
210216
const activityWallet = environment.ONESHOT_ACTIVITY_WALLET_ADDRESS?.trim();
211217
const userWalletRpcUrl = optionalRpcUrl(environment, 'ONESHOT_ARC_RPC_URL');
212218
const mcp = mcpConfig(environment, workspaceId);
213-
if ((activityEndpoint && !activityWallet) || (!activityEndpoint && activityWallet)) {
219+
if (!activityEndpoint && activityWallet) {
214220
throw new Error(
215-
'ONESHOT_GRAPH_QUERY_URL and ONESHOT_ACTIVITY_WALLET_ADDRESS must be configured together',
221+
'A Graph query URL is required when ONESHOT_ACTIVITY_WALLET_ADDRESS is configured',
216222
);
217223
}
218224
if (activityEndpoint) {
@@ -236,11 +242,11 @@ export function loadApiRuntimeConfig(
236242
windowMs: integer(environment, 'ONESHOT_API_RATE_LIMIT_WINDOW_MS', 60_000, 1_000, 3_600_000),
237243
},
238244
...(privyAuth ? { privyAuth } : {}),
239-
...(activityEndpoint && activityWallet
245+
...(activityEndpoint
240246
? {
241247
walletActivity: {
242248
endpoint: activityEndpoint,
243-
wallet: activityWallet,
249+
...(activityWallet ? { wallet: activityWallet } : {}),
244250
...(environment.ONESHOT_GRAPH_API_KEY?.trim()
245251
? { apiKey: environment.ONESHOT_GRAPH_API_KEY.trim() }
246252
: {}),

‎apps/api/src/wallet-activity.ts‎

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,69 @@ export interface WalletActivitySnapshot {
88
readonly transfers: readonly {
99
readonly transaction_hash: string;
1010
readonly log_index: number;
11+
readonly sender?: string;
12+
readonly token_contract?: string;
13+
readonly block_number?: string;
14+
readonly block_timestamp?: string;
15+
readonly network?: 'eip155:5042002';
1116
readonly recipient: string;
1217
readonly amount_atomic: string;
1318
}[];
1419
};
1520
}
1621

1722
export interface WalletActivityPort {
18-
refresh(): Promise<WalletActivitySnapshot>;
23+
refresh(wallets?: readonly string[]): Promise<WalletActivitySnapshot>;
1924
}
2025

21-
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 } } }`;
26+
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 } } }`;
27+
28+
function graphBlockTimestamp(value: unknown): string | undefined {
29+
if (value === undefined) return undefined;
30+
if (
31+
typeof value === 'string' &&
32+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/u.test(value)
33+
) {
34+
return value;
35+
}
36+
if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/u.test(value)) {
37+
throw new Error('Graph activity block timestamp failed validation');
38+
}
39+
const seconds = Number(value);
40+
if (!Number.isSafeInteger(seconds) || seconds < 0) {
41+
throw new Error('Graph activity block timestamp failed validation');
42+
}
43+
const timestamp = new Date(seconds * 1_000);
44+
if (Number.isNaN(timestamp.getTime())) {
45+
throw new Error('Graph activity block timestamp failed validation');
46+
}
47+
return timestamp.toISOString();
48+
}
2249

2350
export class StudioWalletActivityPort implements WalletActivityPort {
2451
constructor(
2552
private readonly options: {
2653
readonly endpoint: string;
27-
readonly wallet: string;
54+
readonly wallet?: string;
2855
readonly apiKey?: string;
2956
readonly fetchFn?: typeof fetch;
3057
},
3158
) {}
32-
async refresh(): Promise<WalletActivitySnapshot> {
59+
async refresh(wallets: readonly string[] = []): Promise<WalletActivitySnapshot> {
60+
const senders = [...(this.options.wallet ? [this.options.wallet] : []), ...wallets].reduce<
61+
string[]
62+
>((unique, wallet) => {
63+
const address = asEvmAddress(wallet).toLowerCase();
64+
if (!unique.includes(address)) unique.push(address);
65+
return unique;
66+
}, []);
67+
if (senders.length === 0) {
68+
return {
69+
freshness: 'UNAVAILABLE',
70+
coverageNote: 'No site payer wallet is recorded for this workspace yet.',
71+
payload: { transfers: [] },
72+
};
73+
}
3374
const response = await (this.options.fetchFn ?? fetch)(this.options.endpoint, {
3475
method: 'POST',
3576
headers: {
@@ -38,7 +79,7 @@ export class StudioWalletActivityPort implements WalletActivityPort {
3879
},
3980
body: JSON.stringify({
4081
query: QUERY,
41-
variables: { sender: asEvmAddress(this.options.wallet) },
82+
variables: { senders },
4283
}),
4384
});
4485
if (!response.ok) throw new Error('Graph activity query is unavailable');
@@ -70,6 +111,17 @@ export class StudioWalletActivityPort implements WalletActivityPort {
70111
return {
71112
transaction_hash: asTransactionHash(row.transactionHash),
72113
log_index: index,
114+
...(typeof row.sender === 'string' ? { sender: asEvmAddress(row.sender) } : {}),
115+
...(typeof row.tokenContract === 'string'
116+
? { token_contract: asEvmAddress(row.tokenContract) }
117+
: {}),
118+
...(typeof row.blockNumber === 'string' && /^(0|[1-9][0-9]*)$/u.test(row.blockNumber)
119+
? { block_number: row.blockNumber }
120+
: {}),
121+
...(row.blockTimestamp === undefined
122+
? {}
123+
: { block_timestamp: graphBlockTimestamp(row.blockTimestamp)! }),
124+
...(row.network === 'eip155:5042002' ? { network: 'eip155:5042002' as const } : {}),
73125
recipient: asEvmAddress(row.recipient),
74126
amount_atomic: row.amountAtomic,
75127
};

‎apps/api/test/app.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ describe('resumable job API boundary', () => {
584584
recorded_settlement_count: 1,
585585
uncertain_job_count: 0,
586586
unmatched_transfer_count: 0,
587+
transactions: [],
587588
transfers: [],
588589
};
589590
},
@@ -677,6 +678,7 @@ describe('resumable job API boundary', () => {
677678
recorded_settlement_count: 1,
678679
uncertain_job_count: 0,
679680
unmatched_transfer_count: 0,
681+
transactions: [],
680682
transfers: [],
681683
});
682684

‎apps/api/test/config.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ describe('API runtime configuration', () => {
6868
});
6969
});
7070

71+
it('uses the canonical worker Graph URL for automatic site activity', () => {
72+
const config = loadApiRuntimeConfig({
73+
...base,
74+
ONESHOT_SUBGRAPH_QUERY_URL: 'https://api.studio.thegraph.com/query/oneshot/arc/1',
75+
});
76+
expect(config.walletActivity).toEqual({
77+
endpoint: 'https://api.studio.thegraph.com/query/oneshot/arc/1',
78+
});
79+
});
80+
7181
it('loads an isolated MCP configuration', () => {
7282
const config = loadApiRuntimeConfig({
7383
...base,

0 commit comments

Comments
 (0)