forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsign-panel.tsx
More file actions
562 lines (533 loc) · 22.6 KB
/
Copy pathsign-panel.tsx
File metadata and controls
562 lines (533 loc) · 22.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
'use client'
import { useConnectWallet, useWallets } from '@privy-io/react-auth'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Otto } from '@/components/brand'
import { Button, Dialog } from '@/components/ui'
import type { Plan, WebTransition } from '@/lib/api'
import { addChainParams, chainName, evmIdOf, explorerTxUrl } from '@/lib/chains'
import { getAddress } from 'viem'
import { cn } from '@/lib/cn'
import { addressOf, truncateAddress } from '@/lib/format'
import { approvals, chainOfPlan, type PlanStep, planSteps, standingApproval } from './model'
import {
BatchAccepted,
type Batching,
SequentialNeedsConsent,
UserRejected,
probeBatching,
sendPlanCalls,
waitForReceipt,
} from './send-calls'
import { gateFor } from './wallet-gate'
/**
* The bottom of the card while a plan can still be signed: the gate, then the
* signature, then the wait for the chain.
*
* The gate is the security control. The sign button does not exist until the
* connected wallet is the account the plan named, on the chain the plan runs
* on; the page never offers to sign from anything else.
*/
export interface SignPanelProps {
plan: Plan
move: (transition: WebTransition) => Promise<unknown>
/** Whether the plan may still be signed, by the page's own clock. */
open: boolean
/** The hash the service holds for a submitted plan, so a reopened page resumes the watch. */
txHash?: string | null | undefined
/**
* Re-run the calls immediately before the wallet is asked to sign: the plan
* was built minutes ago and the last thing a person should do is send a
* transaction the chain has already started refusing.
*
* Untraced and read straight from the chain, never through the wallet.
*/
recheck?: (() => Promise<{ success: boolean; revertReason?: string; failedCall?: number } | null>) | undefined
}
type Phase =
| { kind: 'idle' }
| { kind: 'switching' }
| { kind: 'signing' }
| { kind: 'submitted'; txHash: `0x${string}` }
| { kind: 'confirmed'; txHash: `0x${string}` }
| { kind: 'failed'; txHash: `0x${string}` | null; reason: string }
const isHash = (v: unknown): v is `0x${string}` => typeof v === 'string' && /^0x[0-9a-f]{64}$/i.test(v)
export function SignPanel({ plan, move, open, txHash, recheck }: SignPanelProps) {
const router = useRouter()
const { wallets, ready } = useWallets()
const { connectWallet } = useConnectWallet()
// A page opened on a plan already submitted starts where the plan is.
const [phase, setPhase] = useState<Phase>(() =>
plan.status === 'submitted' && isHash(txHash) ? { kind: 'submitted', txHash } : { kind: 'idle' },
)
const watching = useRef(false)
const [problem, setProblem] = useState<string | null>(null)
/**
* Set when the wallet will not batch and the plan carries an approval.
*
* Not an error state. The person is told exactly what would be left
* standing if they stopped halfway, and chooses. Refusing on their behalf
* blocked every swap in every wallet without EIP-5792, which is nearly all
* of them on an ordinary account.
*/
const [askConsent, setAskConsent] = useState(false)
/**
* Carried in a ref rather than as an argument to `sign`, so the callback
* keeps its identity — and so the consent survives the re-render that
* dismisses the prompt without racing it.
*/
const consented = useRef(false)
const [confirmCancel, setConfirmCancel] = useState(false)
const [cancelling, setCancelling] = useState(false)
/**
* What the wallet says about batching, for the label only.
*
* Never consulted when sending: `sendPlanCalls` offers the batch whatever
* this says. "unknown" is a real answer and stays silent rather than
* guessing, because a wallet that cannot be asked is not a wallet that
* cannot batch.
*/
const [batching, setBatching] = useState<Batching>('unknown')
/** Which call the wallet is on, and how many are behind it. */
const [progress, setProgress] = useState<{ signing: number | null; done: number }>({ signing: null, done: 0 })
const wroteAwaiting = useRef(false)
const chain = chainOfPlan(plan)
const bound = { account: plan.resolution.account.caip10, chain }
const wanted = addressOf(bound.account)
const gate = gateFor(
bound,
wallets.map((w) => ({ address: w.address, chainId: w.chainId })),
)
const wallet = wallets.find((w) => w.address.toLowerCase() === wanted.toLowerCase())
// Memoised: called bare in the body, it defeated the React Compiler's
// memoisation of every callback below it.
const standing = useMemo(() => standingApproval(plan), [plan])
const steps = useMemo(() => planSteps(plan), [plan])
const batched = batching === 'yes'
const signerName = plan.resolution.account.label
? `${plan.resolution.account.label} (${truncateAddress(wanted)})`
: truncateAddress(wanted)
// The moment the named wallet is connected on the right chain, the plan is
// waiting on a signature rather than on a review. Written once.
useEffect(() => {
if (!open || gate.kind !== 'ready' || wroteAwaiting.current || plan.status !== 'awaiting_review') return
wroteAwaiting.current = true
void move({ status: 'awaiting_signature' }).catch(() => {
wroteAwaiting.current = false
})
}, [open, gate.kind, plan.status, move])
/**
* Ask once the right wallet is connected, and only when there is more than
* one call — with a single call there is nothing to batch and nothing worth
* saying about it.
*/
useEffect(() => {
if (!wallet || gate.kind !== 'ready' || steps.length < 2) return
let live = true
void (async () => {
try {
const answer = await probeBatching(await wallet.getEthereumProvider(), wanted, chain)
if (live) setBatching(answer)
} catch {
if (live) setBatching('unknown')
}
})()
return () => {
live = false
}
}, [wallet, gate.kind, wanted, chain, steps.length])
// Resume the receipt watch for a submitted plan through the named wallet's
// provider, when that wallet is connected. Without it the page still shows
// the hash and the explorer; the job (#40) closes the loop server-side.
useEffect(() => {
if (phase.kind !== 'submitted' || !wallet || watching.current) return
watching.current = true
const hash = phase.txHash
void (async () => {
try {
const provider = await wallet.getEthereumProvider()
const outcome = await waitForReceipt(provider, hash)
if (outcome === 'success') {
await move({ status: 'confirmed' })
setPhase({ kind: 'confirmed', txHash: hash })
} else {
await move({ status: 'failed', detail: { reason: 'reverted' } })
setPhase({ kind: 'failed', txHash: hash, reason: 'The transaction reverted on chain.' })
}
} catch {
watching.current = false
setProblem('Lost track of the receipt. The transaction is on chain; check the explorer.')
}
})()
}, [phase, wallet, move])
const switchChain = useCallback(async () => {
if (!wallet) return
const evmId = evmIdOf(chain)
if (evmId === null) return
setPhase({ kind: 'switching' })
setProblem(null)
try {
try {
await wallet.switchChain(evmId)
} catch (err) {
// 4902: the wallet has never heard of the chain. Teach it from the
// registry, then ask again. Anything else is the wallet's answer.
const code = (err as { code?: number }).code
if (code !== 4902 && !/unrecognized|not added|4902/i.test(String((err as Error).message))) throw err
const params = addChainParams(chain)
if (!params) throw err
const provider = await wallet.getEthereumProvider()
await provider.request({ method: 'wallet_addEthereumChain', params: [params] })
await wallet.switchChain(evmId)
}
} catch (err) {
setProblem(`Could not switch to ${chainName(chain)}: ${(err as Error).message}`)
} finally {
setPhase({ kind: 'idle' })
}
}, [wallet, chain])
const sign = useCallback(async () => {
if (!wallet || gate.kind !== 'ready' || plan.outcome.type !== 'calls') return
setProblem(null)
setAskConsent(false)
setProgress({ signing: null, done: 0 })
setPhase({ kind: 'signing' })
let txHash: `0x${string}` | null = null
try {
const provider = await wallet.getEthereumProvider()
// The last check before the wallet opens. A run that cannot answer says
// nothing and does not stop anybody; one that reverts does, because the
// alternative is a signature that burns a fee for nothing.
if (recheck) {
const fresh = await recheck()
if (fresh && !fresh.success) {
setPhase({ kind: 'idle' })
const which = fresh.failedCall ? `Call ${fresh.failedCall}` : 'This batch'
setProblem(
`${which} now reverts against the chain${fresh.revertReason ? `: ${fresh.revertReason}` : ''}. ` +
'Nothing was sent. Ask the agent to prepare it again.',
)
return
}
}
const sent = await sendPlanCalls({
provider,
from: wanted,
chainId: chain,
calls: plan.outcome.calls,
// Nothing could be left standing, or the person has been shown what
// would be and said yes.
sequentialIsSafe: plan.outcome.calls.length === 1 || approvals(plan).length === 0 || consented.current,
onStep: (index, phase) =>
setProgress((held) =>
phase === 'signing' ? { ...held, signing: index } : { signing: null, done: index + 1 },
),
})
txHash = sent.txHash
await move({ status: 'submitted', detail: { txHash } })
// The watch effect takes it from here, for this page and for any reopened one.
setPhase({ kind: 'submitted', txHash })
} catch (err) {
if (err instanceof SequentialNeedsConsent) {
setPhase({ kind: 'idle' })
setAskConsent(true)
return
}
if (err instanceof UserRejected) {
setPhase({ kind: 'idle' })
setProblem(err.message)
return
}
if (err instanceof BatchAccepted) {
// Never idle again: idle would offer the sign button, and the calls are
// already the wallet's. Nothing to write yet either — submitted needs a hash.
setPhase({ kind: 'failed', txHash: null, reason: `${err.message} Check your wallet's activity before doing anything else; this request was not sent twice.` })
return
}
// Submitted but the wait failed: the chain still has it. Say so rather than
// calling it failed, and leave the plan as submitted for the job (#40).
if (txHash) {
setPhase({ kind: 'submitted', txHash })
setProblem('Lost track of the receipt. The transaction is on chain; check the explorer.')
return
}
setPhase({ kind: 'idle' })
setProblem((err as Error).message || 'The wallet did not send it.')
}
}, [wallet, gate.kind, plan, wanted, chain, move, recheck])
const cancel = useCallback(async () => {
setCancelling(true)
try {
await move({ status: 'cancelled' })
setConfirmCancel(false)
} catch (err) {
setProblem((err as Error).message)
} finally {
setCancelling(false)
}
}, [move])
const explorer = (hash: `0x${string}`) => explorerTxUrl(chain, hash)
if (phase.kind === 'confirmed') {
return (
<div className="flex flex-col items-center gap-2.5 text-center">
<div className="relative flex h-[88px] w-[88px] items-center justify-center">
<span aria-hidden className="ot-settle-ripple absolute h-16 w-16 rounded-full bg-[var(--ot-navy-soft)]" />
<div className="relative">
<Otto pose="confirmed" size={88} label="Otto, arms up" />
</div>
</div>
<span className="font-[family-name:var(--ot-font-display)] text-[19px] font-bold">Signed and settled</span>
<p className="m-0 text-[12.5px] leading-[1.45] text-[var(--ot-text-2)]">
{plan.humanPlan.summary}. Confirmed on {chainName(chain)}.
</p>
<div className="flex w-full gap-2">
{explorer(phase.txHash) ? (
<Button variant="secondary" size="sm" fullWidth onClick={() => window.open(explorer(phase.txHash)!, '_blank', 'noreferrer')}>
View on the explorer
</Button>
) : null}
<Button variant="secondary" size="sm" fullWidth onClick={() => router.push('/portfolio')}>
Back to portfolio
</Button>
</div>
</div>
)
}
if (phase.kind === 'submitted') {
return (
<div className="flex flex-col gap-2 rounded-[10px] bg-[var(--ot-card)] px-3 py-[11px]">
<div className="flex items-center gap-2">
<span aria-hidden className="ot-ring h-4 w-4 flex-none rounded-full border-2 border-[var(--ot-plan-border)] border-t-[var(--ot-plan)]" />
<span className="text-[13.5px] font-semibold">Pending confirmation</span>
</div>
<p className="m-0 text-[12.5px] leading-[1.45] text-[var(--ot-text-2)]">
Your wallet sent it. Close this page if you like — the transaction finishes either way.
</p>
{explorer(phase.txHash) ? (
<a href={explorer(phase.txHash)!} target="_blank" rel="noreferrer" className="text-[12px] text-[var(--ot-plan-text)]">
Follow it on the explorer
</a>
) : null}
{problem ? <p className="m-0 text-[12px] text-[var(--ot-warn-text)]">{problem}</p> : null}
</div>
)
}
if (phase.kind === 'failed') {
return (
<div className="flex flex-col gap-2 rounded-[10px] bg-[var(--ot-block-bg)] px-3 py-[11px]">
<span className="text-[13.5px] font-semibold text-[var(--ot-block-text)]">It did not go through</span>
<p className="m-0 text-[12.5px] leading-[1.45] text-[var(--ot-text)]">{phase.reason} Nothing else was sent.</p>
{phase.txHash && explorer(phase.txHash) ? (
<a href={explorer(phase.txHash)!} target="_blank" rel="noreferrer" className="text-[12px] text-[var(--ot-plan-text)]">
See the failed transaction
</a>
) : null}
</div>
)
}
if (!open) return null
const busy = phase.kind === 'signing' || phase.kind === 'switching'
return (
<div className="flex flex-col gap-3">
<PlanStepList
steps={steps}
batched={batched}
known={batching !== 'unknown'}
signing={phase.kind === 'signing'}
progress={progress}
/>
{problem ? (
<p role="alert" className="m-0 rounded-[8px] bg-[var(--ot-warn-bg)] px-3 py-2 text-[12.5px] text-[var(--ot-warn-text)]">
{problem}
</p>
) : null}
{/*
What signing will actually involve, once the wallet is connected and
there is more than one step. Silent on "unknown": a wallet that could
not be asked is not a wallet that cannot batch, and the send path
tries regardless.
*/}
{/*
The wallet will not batch. Say exactly what stopping halfway would
leave behind, then let the person decide — an allowance for a named
amount to the router this plan already shows is a risk somebody can
weigh, and refusing on their behalf just ended the flow.
*/}
{askConsent ? (
<div role="alert" className="flex flex-col gap-2 rounded-[10px] bg-[var(--ot-warn-bg)] px-3 py-2.5">
<p className="m-0 text-[12.5px] leading-[1.5] text-[var(--ot-warn-text)]">
This wallet cannot send both steps together, so you would approve first and swap second.
{standing
? standing.unlimited
? ` If you stop after the first, an unlimited allowance to ${truncateAddress(addressOf(standing.spender))} would remain.`
: ` If you stop after the first, an allowance for ${standing.amount} ${standing.symbol} to ${truncateAddress(addressOf(standing.spender))} would remain — nothing more, and only to that address.`
: ''}
</p>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setAskConsent(false)}>
Not now
</Button>
<Button
variant="primary"
size="sm"
onClick={() => {
consented.current = true
void sign()
}}
>
Sign one at a time
</Button>
</div>
</div>
) : null}
<div className="flex gap-2">
<Button variant="secondary" size="lg" fullWidth disabled={busy} onClick={() => setConfirmCancel(true)}>
Cancel
</Button>
{gate.kind === 'ready' ? (
<Button variant="primary" size="lg" fullWidth disabled={busy || !ready} onClick={() => void sign()}>
{phase.kind === 'signing' ? 'Check your wallet…' : 'Sign'}
</Button>
) : gate.kind === 'wrong_chain' ? (
<Button variant="primary" size="lg" fullWidth disabled={busy} onClick={switchChain}>
{phase.kind === 'switching' ? 'Switching…' : `Switch to ${chainName(chain)}`}
</Button>
) : (
<Button variant="primary" size="lg" fullWidth disabled={!ready} onClick={() => connectWallet({ suggestedAddress: getAddress(wanted) })}>
Connect wallet
</Button>
)}
</div>
<p className="m-0 text-center text-[12px] text-[var(--ot-text-2)]">
{gate.kind === 'ready' ? (
<>
Signing with <strong className="text-[var(--ot-text)]">{signerName}</strong> on {chainName(chain)}
</>
) : gate.kind === 'wrong_chain' ? (
<>
<strong className="text-[var(--ot-text)]">{signerName}</strong> is on {chainName(gate.on)}; this plan runs on{' '}
{chainName(chain)}
</>
) : gate.kind === 'wrong_account' ? (
<>
Connected as {truncateAddress(gate.connected)}. This plan needs{' '}
<strong className="text-[var(--ot-text)]">{signerName}</strong>.
</>
) : (
<>
Connect <strong className="text-[var(--ot-text)]">{signerName}</strong> to sign
</>
)}
</p>
<Dialog
open={confirmCancel}
onClose={() => (cancelling ? undefined : setConfirmCancel(false))}
title="Cancel this request?"
tone="destructive"
description="The agent will be told it was cancelled. Nothing has been signed, and nothing will be."
actions={
<>
<Button variant="secondary" disabled={cancelling} onClick={() => setConfirmCancel(false)}>
Keep it
</Button>
<Button variant="destructive" disabled={cancelling} onClick={cancel}>
{cancelling ? 'Cancelling…' : 'Cancel request'}
</Button>
</>
}
/>
</div>
)
}
/**
* Every call the wallet will be asked for, in order.
*
* The one thing this has to get right is honesty about how many signatures
* are coming. A batched plan is one signature over the whole list, so the
* rows are bracketed together and share a state; a sequential one is a
* signature each, so the rows are numbered and only the current one is lit.
*
* "unknown" gets the plain numbered list with no claim either way, because a
* wallet that could not be asked is not a wallet that cannot batch.
*/
function PlanStepList({
steps,
batched,
known,
signing,
progress,
}: {
steps: readonly PlanStep[]
batched: boolean
known: boolean
signing: boolean
progress: { signing: number | null; done: number }
}) {
if (steps.length === 0) return null
const many = steps.length > 1
const heading = !many
? 'One signature in your wallet'
: batched
? `${steps.length} steps, one signature`
: known
? `${steps.length} steps, a signature each`
: `${steps.length} steps in your wallet`
return (
<div className="flex flex-col gap-2 rounded-[10px] bg-[var(--ot-card)] px-3 py-[11px]">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[11.5px] text-[var(--ot-text-3)]">{heading}</span>
{many && batched ? (
<span className="flex items-center gap-1 text-[10.5px] font-semibold text-[var(--ot-ok-text)]">
<BatchMark />
batched
</span>
) : null}
</div>
<div className={cn('flex gap-2.5', many && batched && 'ot-batch-group')}>
{/* One brace for a batch: the rows are one action to the wallet. */}
{many && batched ? <span aria-hidden className="ot-batch-brace mt-0.5 mb-0.5 w-[3px] flex-none rounded-full" /> : null}
<ol className="m-0 flex flex-1 list-none flex-col gap-1.5 p-0">
{steps.map((step, i) => {
const done = batched ? false : i < progress.done
const active = batched ? signing : signing && progress.signing === i
return (
<li key={step.index} className="flex items-center gap-2">
<span
aria-hidden
className={cn(
'flex h-[19px] w-[19px] flex-none items-center justify-center rounded-full text-[10.5px] font-semibold transition-colors',
done
? 'bg-[var(--ot-ok-bg)] text-[var(--ot-ok-text)]'
: active
? 'bg-[var(--ot-plan)] text-[var(--ot-on-state)]'
: 'bg-[var(--ot-surface-3)] text-[var(--ot-text-3)]',
)}
>
{done ? '✓' : batched ? '•' : step.index}
</span>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-1.5">
<span className={cn('text-[13px]', active ? 'font-semibold text-[var(--ot-text)]' : 'text-[var(--ot-text-2)]')}>
{step.label}
</span>
{step.detail ? <span className="text-[11px] text-[var(--ot-text-3)]">{step.detail}</span> : null}
</span>
{active && !batched ? (
<span className="flex-none text-[10.5px] font-medium text-[var(--ot-plan-text)]">in your wallet</span>
) : null}
</li>
)
})}
</ol>
</div>
</div>
)
}
/** Two shapes closing into one. Static: this page holds still. */
function BatchMark() {
return (
<svg aria-hidden viewBox="0 0 12 12" className="h-3 w-3 flex-none" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
<rect x="1.2" y="1.3" width="9.6" height="9.4" rx="2.6" />
<path d="M3.9 6h4.2M6 3.9v4.2" strokeLinecap="round" strokeWidth="1.2" opacity="0.6" />
</svg>
)
}