Skip to content

Commit 228ed37

Browse files
committed
fix(shared,client): harden Safe7579 session-key execution
1 parent 5c52bfb commit 228ed37

28 files changed

Lines changed: 1635 additions & 911 deletions

File tree

.plans/features/popup-sidepanel-walkthrough/eval/session-state.archive-2026-03-31.md

Lines changed: 686 additions & 0 deletions
Large diffs are not rendered by default.

.plans/features/popup-sidepanel-walkthrough/eval/session-state.md

Lines changed: 156 additions & 634 deletions
Large diffs are not rendered by default.

docs/reference/hackathon-demo-voiceover-script.md renamed to docs/reference/demo-voiceover-script.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ For shot sequencing and operator actions, use
1515

1616
### Opening
1717

18-
Communities do not have an information problem. They have a fragmentation problem.
18+
We do not have an information problem. We have a fragmentation problem.
1919

2020
Important context already exists in messy tabs, notes, links, conversations, and half-finished
21-
research. What is usually missing is a way to turn that messy knowledge into shared opportunity,
22-
coordinated governance, and durable memory.
21+
research — scattered across people and tools. What is usually missing is a way to turn that
22+
scattered knowledge into clear opportunity, whether for yourself, a team, or an entire community.
2323

2424
That is what Coop is for.
2525

@@ -133,14 +133,13 @@ It turns messy knowledge into opportunity, coordinated governance, and durable p
133133
### Future Coda
134134

135135
What comes next is deeper knowledge exploration, stronger Coop OS patterns, richer mobile and
136-
call-based participation, and more ways for communities to turn what they know into coordinated
137-
opportunity.
136+
call-based participation, and more ways to turn what you know into coordinated opportunity.
138137

139138
## Shorter Alt Close
140139

141140
If you want a sharper final line:
142141

143-
Coop helps communities move from scattered context to shared opportunity, from shared opportunity to
142+
Coop helps you move from scattered context to shared opportunity, from shared opportunity to
144143
governance, and from governance to durable memory.
145144

146145
## Delivery Notes

packages/app/src/views/Landing/index.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { SetupInsightsInput } from '@coop/shared';
2-
import { clipboardPasteFallbackMessage, getRitualLenses, pasteClipboardText } from '@coop/shared';
2+
import {
3+
clipboardPasteFallbackMessage,
4+
getRitualLenses,
5+
pasteClipboardText,
6+
synthesizeTranscriptsToPurpose,
7+
} from '@coop/shared';
38
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react';
49
import { DevTunnelBadge } from '../../components/DevTunnelBadge';
510
import { LanguageSelector } from '../../components/LanguageSelector';
@@ -119,6 +124,7 @@ export function App({
119124
});
120125
const focusOpenCardRef = useRef<TranscriptKey | null>(null);
121126
const focusReturnCardRef = useRef<TranscriptKey | null>(null);
127+
const lastSynthesizedFromRef = useRef('');
122128

123129
const [setupInput, setSetupInput] = useState<SetupInsightsInput>(() => initialDraft.setupInput);
124130
const [transcripts, setTranscripts] = useState<TranscriptMap>(() => initialDraft.transcripts);
@@ -145,6 +151,22 @@ export function App({
145151
);
146152
const completedLensCount = lensProgress.filter((progress) => progress.status === 'ready').length;
147153
const allLensesReady = completedLensCount === ritualCardMappings.length;
154+
155+
useEffect(() => {
156+
if (!allLensesReady || setupInput.purpose) {
157+
return;
158+
}
159+
const transcriptFingerprint = `${transcripts.capital}|${transcripts.impact}|${transcripts.governance}|${transcripts.knowledge}`;
160+
if (transcriptFingerprint === lastSynthesizedFromRef.current) {
161+
return;
162+
}
163+
lastSynthesizedFromRef.current = transcriptFingerprint;
164+
const synthesized = synthesizeTranscriptsToPurpose(transcripts);
165+
if (synthesized) {
166+
setSetupInput((current) => ({ ...current, purpose: synthesized }));
167+
}
168+
}, [allLensesReady, setupInput.purpose, transcripts]);
169+
148170
const openCardIndex = openCardId ? ritualLenses.findIndex((lens) => lens.id === openCardId) : -1;
149171
const openCardLens = openCardIndex >= 0 ? ritualLenses[openCardIndex] : null;
150172
const openCardMapping = openCardIndex >= 0 ? ritualCardMappings[openCardIndex] : null;

packages/extension/src/background/handlers/__tests__/agent-observation-conditions.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ describe('isRitualReviewDue', () => {
6464
skillId: 'review-digest',
6565
},
6666
status: 'published',
67-
createdAt: '2026-03-25T00:00:00.000Z',
67+
// Must be within the 7-day freshness window relative to now
68+
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
6869
}),
6970
] as never[],
7071
}),

packages/extension/src/background/handlers/__tests__/archive-handlers.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,8 @@ describe('archive handlers', () => {
10791079

10801080
expect(result).toEqual({
10811081
ok: false,
1082-
error: 'A stored passkey session is required before a member can register proofs on Filecoin.',
1082+
error:
1083+
'A stored passkey session is required before a member can register proofs on Filecoin.',
10831084
});
10841085
expect(mocks.saveState).not.toHaveBeenCalled();
10851086
expect(viemMocks.createWalletClient).not.toHaveBeenCalled();

packages/extension/src/background/handlers/__tests__/session-execution.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const sharedMocks = vi.hoisted(() => ({
4444
getAuthSession: vi.fn(),
4545
getEncryptedSessionMaterial: vi.fn(),
4646
getSessionCapability: vi.fn(),
47+
getSessionCapabilityUseStubSignature: vi.fn(
48+
({ capability }: { capability: SessionCapability }) => `wrapped:${capability.id}:validator-stub`,
49+
),
4750
incrementSessionCapabilityUsage: vi.fn((capability: SessionCapability) => ({
4851
...capability,
4952
usedCount: ((capability as SessionCapability & { usedCount?: number }).usedCount ?? 0) + 1,
@@ -77,6 +80,20 @@ const sharedMocks = vi.hoisted(() => ({
7780
saveEncryptedSessionMaterial: vi.fn(async () => undefined),
7881
saveSessionCapability: vi.fn(async () => undefined),
7982
saveSessionCapabilityLogEntry: vi.fn(async () => undefined),
83+
sendSmartAccountTransactionWithCoopGasFallback: vi.fn(async () => ({
84+
txHash: '0xtxhash',
85+
userOperationHash: '0xuophash',
86+
receipt: {
87+
transactionHash: '0xreceipt',
88+
},
89+
})),
90+
signSessionCapabilityUserOperation: vi.fn(
91+
async ({
92+
capability,
93+
}: {
94+
capability: SessionCapability;
95+
}) => `wrapped:${capability.id}:validator-signature`,
96+
),
8097
validateSessionCapabilityForBundle: vi.fn(),
8198
wrapUseSessionSignature: vi.fn(
8299
({
@@ -126,6 +143,7 @@ vi.mock('@coop/shared', async (importOriginal) => {
126143
getAuthSession: sharedMocks.getAuthSession,
127144
getEncryptedSessionMaterial: sharedMocks.getEncryptedSessionMaterial,
128145
getSessionCapability: sharedMocks.getSessionCapability,
146+
getSessionCapabilityUseStubSignature: sharedMocks.getSessionCapabilityUseStubSignature,
129147
incrementSessionCapabilityUsage: sharedMocks.incrementSessionCapabilityUsage,
130148
listSessionCapabilities: sharedMocks.listSessionCapabilities,
131149
listSessionCapabilityLogEntries: sharedMocks.listSessionCapabilityLogEntries,
@@ -137,6 +155,9 @@ vi.mock('@coop/shared', async (importOriginal) => {
137155
saveEncryptedSessionMaterial: sharedMocks.saveEncryptedSessionMaterial,
138156
saveSessionCapability: sharedMocks.saveSessionCapability,
139157
saveSessionCapabilityLogEntry: sharedMocks.saveSessionCapabilityLogEntry,
158+
sendSmartAccountTransactionWithCoopGasFallback:
159+
sharedMocks.sendSmartAccountTransactionWithCoopGasFallback,
160+
signSessionCapabilityUserOperation: sharedMocks.signSessionCapabilityUserOperation,
140161
validateSessionCapabilityForBundle: sharedMocks.validateSessionCapabilityForBundle,
141162
wrapUseSessionSignature: sharedMocks.wrapUseSessionSignature,
142163
};
@@ -291,6 +312,10 @@ describe('session execution paths', () => {
291312
sendTransaction: vi.fn(async () => '0xtxhash'),
292313
};
293314
const baseAccount = {
315+
entryPoint: {
316+
address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
317+
version: '0.7' as const,
318+
},
294319
getStubSignature: vi.fn(async () => 'validator-stub'),
295320
signUserOperation: vi.fn(async () => 'validator-signature'),
296321
};
@@ -358,7 +383,7 @@ describe('session execution paths', () => {
358383
expect.objectContaining({
359384
account: expect.objectContaining({
360385
address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
361-
type: 'erc7579-implementation',
386+
type: 'safe',
362387
deployedOnChains: [11155111],
363388
}),
364389
}),
@@ -367,7 +392,7 @@ describe('session execution paths', () => {
367392
expect.objectContaining({
368393
account: expect.objectContaining({
369394
address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
370-
type: 'erc7579-implementation',
395+
type: 'safe',
371396
deployedOnChains: [11155111],
372397
}),
373398
}),
@@ -492,6 +517,8 @@ describe('session execution paths', () => {
492517
version: '1.4.1',
493518
safe4337ModuleAddress: '0x7579EE8307284F293B1927136486880611F20002',
494519
erc7579LaunchpadAddress: '0x7579011aB74c46090561ea277Ba79D510c6C00ff',
520+
attesters: ['0x000000333034E9f539ce08819E12c1b8Cb29084d'],
521+
attestersThreshold: 1,
495522
}),
496523
);
497524
});
@@ -629,6 +656,9 @@ describe('session execution paths', () => {
629656
});
630657

631658
it('records failed Green Goods session executions and rethrows the failure', async () => {
659+
sharedMocks.sendSmartAccountTransactionWithCoopGasFallback.mockRejectedValueOnce(
660+
new Error('Bundler rejected user operation.'),
661+
);
632662
const sendTransaction = vi.fn(async () => {
633663
throw new Error('Bundler rejected user operation.');
634664
});

packages/extension/src/background/handlers/archive.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ async function ensureLocalMemberFvmSigner(passkeyCredentialId: string) {
120120
return existingSigner;
121121
}
122122

123-
const existingBinding = await getLocalFvmSignerBinding(db, configuredFvmChain, passkeyCredentialId);
123+
const existingBinding = await getLocalFvmSignerBinding(
124+
db,
125+
configuredFvmChain,
126+
passkeyCredentialId,
127+
);
124128
if (existingBinding) {
125129
throw new Error(
126130
'Local Filecoin signer data is incomplete on this device. Restore the original browser profile or clear the saved signer binding before retrying.',
@@ -1305,7 +1309,8 @@ export async function handleFvmRegistration(
13051309
if (!authSession?.passkey) {
13061310
return {
13071311
ok: false,
1308-
error: 'A stored passkey session is required before a member can register proofs on Filecoin.',
1312+
error:
1313+
'A stored passkey session is required before a member can register proofs on Filecoin.',
13091314
} satisfies RuntimeActionResponse;
13101315
}
13111316

@@ -1375,9 +1380,7 @@ export async function handleFvmRegistration(
13751380
archiveScope: receipt.scope,
13761381
});
13771382

1378-
let localSigner:
1379-
| Awaited<ReturnType<typeof ensureLocalMemberFvmSigner>>
1380-
| undefined;
1383+
let localSigner: Awaited<ReturnType<typeof ensureLocalMemberFvmSigner>> | undefined;
13811384
try {
13821385
localSigner = await ensureLocalMemberFvmSigner(authSession.passkey.id);
13831386
const fvmConfig = getFvmChainConfig(configuredFvmChain);

packages/extension/src/background/handlers/session.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
getAuthSession,
2121
getEncryptedSessionMaterial,
2222
getGreenGoodsDeployment,
23+
getSmartSessionsValidatorNonceKey,
24+
getCoopChainConfig,
2325
getSessionCapability,
26+
getSessionCapabilityUseStubSignature,
2427
incrementSessionCapabilityUsage,
2528
listSessionCapabilities,
2629
listSessionCapabilityLogEntries,
@@ -32,10 +35,11 @@ import {
3235
saveEncryptedSessionMaterial,
3336
saveSessionCapability,
3437
saveSessionCapabilityLogEntry,
38+
sendSmartAccountTransactionWithCoopGasFallback,
39+
signSessionCapabilityUserOperation,
3540
toCoopSafeSmartAccount,
3641
usesCoopSafeErc7579,
3742
validateSessionCapabilityForBundle,
38-
wrapUseSessionSignature,
3943
} from '@coop/shared';
4044
import {
4145
installModule as buildModuleInstallExecutions,
@@ -183,8 +187,33 @@ export async function ensureSessionCapabilityReadyLive(input: {
183187
onchainState: input.onchainState,
184188
});
185189
const { modules } = buildSmartSession({ capability: input.capability });
190+
const modulesToEnsure = [
191+
modules.validator,
192+
{
193+
...modules.fallback,
194+
functionSig: modules.fallback.functionSig ?? modules.fallback.selector,
195+
},
196+
];
197+
const waitForModuleInstalled = async (
198+
module: (typeof modulesToEnsure)[number],
199+
timeoutMs = 30_000,
200+
) => {
201+
const startedAt = Date.now();
202+
while (Date.now() - startedAt < timeoutMs) {
203+
const installed = await checkModuleInstalled({
204+
client: context.publicClient as Parameters<typeof checkModuleInstalled>[0]['client'],
205+
account: context.moduleAccount,
206+
module,
207+
});
208+
if (installed) {
209+
return;
210+
}
211+
await new Promise((resolve) => setTimeout(resolve, 1_500));
212+
}
213+
throw new Error(`Timed out waiting for ${module.type} module installation to finalize.`);
214+
};
186215

187-
for (const module of [modules.validator, modules.fallback]) {
216+
for (const module of modulesToEnsure) {
188217
const installed = await checkModuleInstalled({
189218
client: context.publicClient as Parameters<typeof checkModuleInstalled>[0]['client'],
190219
account: context.moduleAccount,
@@ -200,12 +229,15 @@ export async function ensureSessionCapabilityReadyLive(input: {
200229
module,
201230
});
202231
for (const execution of executions) {
203-
await context.smartClient.sendTransaction({
232+
await sendSmartAccountTransactionWithCoopGasFallback({
233+
smartClient: context.smartClient,
234+
accountTypeHint: 'safe',
204235
to: execution.to,
205236
data: execution.data,
206237
value: execution.value as bigint,
207238
});
208239
}
240+
await waitForModuleInstalled(module);
209241
}
210242

211243
const enabled = await checkSessionCapabilityEnabled({
@@ -288,21 +320,25 @@ export async function createSessionExecutionContext(input: {
288320
chainKey: input.onchainState.chainKey,
289321
address: input.onchainState.safeAddress as Address,
290322
useErc7579: usesCoopSafeErc7579(input.onchainState),
323+
nonceKey: getSmartSessionsValidatorNonceKey(),
291324
});
292325
const account = {
293326
...baseAccount,
294327
async getStubSignature() {
295-
const validatorSignature = await baseAccount.getStubSignature();
296-
return wrapUseSessionSignature({
328+
return getSessionCapabilityUseStubSignature({
297329
capability: input.capability,
298-
validatorSignature,
299330
});
300331
},
301332
async signUserOperation(parameters: Parameters<typeof baseAccount.signUserOperation>[0]) {
302-
const validatorSignature = await baseAccount.signUserOperation(parameters);
303-
return wrapUseSessionSignature({
333+
return signSessionCapabilityUserOperation({
304334
capability: input.capability,
305-
validatorSignature,
335+
signer: owner,
336+
userOperation: parameters,
337+
chainId:
338+
parameters.chainId ?? getCoopChainConfig(input.capability.scope.chainKey).chain.id,
339+
entryPointAddress: baseAccount.entryPoint.address,
340+
entryPointVersion: baseAccount.entryPoint.version,
341+
sender: input.onchainState.safeAddress as Address,
306342
});
307343
},
308344
};
@@ -416,12 +452,13 @@ export async function buildGreenGoodsSessionExecutor(input: {
416452
);
417453

418454
try {
419-
const txHash = await context.smartClient.sendTransaction({
455+
const result = await sendSmartAccountTransactionWithCoopGasFallback({
456+
smartClient: context.smartClient,
457+
accountTypeHint: 'safe',
420458
to,
421459
data,
422460
value: value ?? 0n,
423461
});
424-
const receipt = await context.publicClient.waitForTransactionReceipt({ hash: txHash });
425462
const updatedCapability = incrementSessionCapabilityUsage(capability);
426463
await saveSessionCapability(db, updatedCapability);
427464
await saveSessionCapabilityLogEntry(
@@ -437,13 +474,16 @@ export async function buildGreenGoodsSessionExecutor(input: {
437474
}),
438475
);
439476
return {
440-
txHash,
441-
receipt,
477+
txHash: result.txHash,
478+
receipt: result.receipt,
442479
safeAddress: input.coop.onchainState.safeAddress as Address,
443480
};
444481
} catch (error) {
445-
const detail =
482+
const rawDetail =
446483
error instanceof Error ? error.message : 'Session-key execution failed unexpectedly.';
484+
const detail = rawDetail.includes('AA24 signature error')
485+
? 'Session-key signature was rejected onchain by Smart Sessions (AA24). The Safe and owner path are still intact, but this session key must be re-issued or re-enabled before autonomous execution can continue.'
486+
: rawDetail;
447487
await saveSessionCapabilityLogEntry(
448488
db,
449489
createSessionCapabilityLogEntry({
@@ -456,7 +496,9 @@ export async function buildGreenGoodsSessionExecutor(input: {
456496
replayId: input.bundle.replayId,
457497
}),
458498
);
459-
throw error;
499+
throw new Error(detail, {
500+
cause: error instanceof Error ? error : undefined,
501+
});
460502
}
461503
};
462504
}

packages/extension/src/global.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ button:disabled {
369369
justify-content: center;
370370
gap: 6px;
371371
padding: 24px 16px;
372+
/* Fill remaining sidepanel content area so justify-content centers visually */
373+
min-height: calc(100vh - 12rem);
372374
}
373375

374376
.empty-state__icon {

0 commit comments

Comments
 (0)