forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodel.ts
More file actions
460 lines (415 loc) · 16.8 KB
/
Copy pathmodel.ts
File metadata and controls
460 lines (415 loc) · 16.8 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
import type { AssetDelta, DecodedAction, Plan, PlanStatusName, PlanWarning, Simulation } from '@/lib/api'
import { chainName } from '@/lib/chains'
import { addressOf, formatAmount, truncateAddress } from '@/lib/format'
/**
* What the page shows, derived from the stored plan and nothing else. Every
* function here is a reading of data the service already verified; none of
* them decodes, hashes or checks anything, and none of them may.
*/
/** Statuses a plan can never leave. Mirrors TERMINAL_STATUSES in the service. */
export const TERMINAL: ReadonlySet<PlanStatusName> = new Set([
'confirmed',
'failed',
'expired',
'blocked',
'superseded',
'cancelled',
])
/**
* The status right now. The service derives expiry the same way; doing it
* here too means the page flips to expired the moment the clock passes,
* without a round trip, and the sign button goes with it.
*/
export function effectiveStatus(plan: Pick<Plan, 'status' | 'expiresAt'>, now = Date.now()): PlanStatusName {
if (TERMINAL.has(plan.status) || plan.status === 'submitted') return plan.status
return new Date(plan.expiresAt).getTime() <= now ? 'expired' : plan.status
}
/** Only these may reach the sign button. */
export function canSign(status: PlanStatusName): boolean {
return status === 'awaiting_review' || status === 'awaiting_signature'
}
export function chainOfPlan(plan: Plan): string {
const [namespace, reference] = plan.resolution.account.caip10.split(':')
return `${namespace}:${reference}`
}
export interface AssetWords {
symbol: string
decimals: number
}
/** The words for an asset id, if the plan recorded them. */
export function assetWords(plan: Plan, assetId: string): AssetWords | null {
const hit = plan.humanPlan.assets?.find((a) => a.id.toLowerCase() === assetId.toLowerCase())
return hit ? { symbol: hit.symbol, decimals: hit.decimals } : null
}
export interface AssetChange {
direction: 'out' | 'in'
/** Formatted, e.g. "500" — never a float. */
amount: string
symbol: string
/** "leaves Main", "arrives at koshik.eth" */
where: string
/** The CAIP-19 id, so the row can find its icon. */
assetId: string
}
/**
* What moves.
*
* The simulation's traced balances when there are any, because those were
* observed; the intent when there are not, because a plan on a chain nobody
* simulates still has to say what it will do. The two are not interchangeable
* and the page labels which one it is showing — `changeSource` is how it
* knows.
*
* The simulation traces the signing account's balances, so every row is about
* that wallet: what left it, and anything that arrived in it.
*/
export function assetChanges(plan: Plan, live?: Simulation | null): AssetChange[] {
const holder = holderOf(plan)
const traced = (live ?? plan.simulation)?.assetChanges ?? []
if (traced.length > 0) return traced.map((delta) => observedRow(delta, holder))
if (plan.intent.kind === 'transfer') {
const words = assetWords(plan, plan.intent.asset)
if (!words) return []
return [
{
direction: 'out',
amount: formatAmount(plan.intent.amount, words.decimals),
symbol: words.symbol,
where: `leaves ${holder}`,
assetId: plan.intent.asset,
},
]
}
/**
* A trade, read off the request when no simulation has been observed.
*
* Without this a swap showed no asset rows at all until a simulation
* landed — so the amounts, and the icons that hang off them, were simply
* absent on the page whose whole job is to say what moves. The card labels
* these as coming from the request, which is what they are: the quote's
* expectation, not an observation.
*/
if (plan.intent.kind === 'swap' || plan.intent.kind === 'bridge') {
const { from, to, amountIn } = plan.intent
const rows: AssetChange[] = []
const paid = assetWords(plan, from)
if (paid && amountIn) {
rows.push({
direction: 'out',
amount: formatAmount(amountIn, paid.decimals),
symbol: paid.symbol,
where: `leaves ${holder}`,
assetId: from,
})
}
const got = assetWords(plan, to)
const expected = plan.quote.expectedOut
if (got && expected) {
const crossing = chainOfAsset(to) !== chainOfAsset(from)
rows.push({
direction: 'in',
amount: formatAmount(expected, got.decimals),
symbol: got.symbol,
where: crossing ? `arrives on ${chainName(chainOfAsset(to))}` : `arrives in ${holder}`,
assetId: to,
})
}
return rows
}
return []
}
/** The CAIP-2 chain an asset id names. */
function chainOfAsset(assetId: string): string {
const [namespace, rest] = assetId.split(':')
return `${namespace}:${rest?.split('/')[0] ?? ''}`
}
function holderOf(plan: Plan): string {
return plan.resolution.account.label ?? truncateAddress(addressOf(plan.resolution.account.caip10))
}
/**
* Where the rows came from, which the page has to say out loud.
*
* "live" is a simulation the browser ran while the person was looking, which
* is the only one that describes the chain as it is now. "stored" is the run
* the service did when the plan was built — true when it ran, and older than
* the reader. "request" is the intent, which is a promise rather than an
* observation.
*/
export type ChangeSource = 'live' | 'stored' | 'request'
export function changeSource(plan: Plan, live?: Simulation | null): ChangeSource {
if ((live?.assetChanges.length ?? 0) > 0) return 'live'
if ((plan.simulation?.assetChanges.length ?? 0) > 0) return 'stored'
return 'request'
}
export const SOURCE_LABEL: Readonly<Record<ChangeSource, string>> = {
live: 'simulated just now',
stored: 'simulated when the plan was built',
request: 'from the request',
}
function observedRow(delta: AssetDelta, holder: string): AssetChange {
const out = delta.diff.startsWith('-')
const magnitude = out ? delta.diff.slice(1) : delta.diff
return {
direction: out ? 'out' : 'in',
// A token the simulator could not name is shown in its own units rather
// than converted by a guessed number of decimals.
amount: delta.decimals === null ? magnitude : formatAmount(magnitude, delta.decimals),
symbol: delta.symbol ?? 'units',
where: out ? `leaves ${holder}` : `arrives in ${holder}`,
assetId: delta.assetId,
}
}
/**
* The line under the asset changes: who simulated, at which block, and that
* it is a prediction. Null when nothing ran, so the page can say that too
* rather than implying a pass.
*/
export function simulationNote(plan: Plan, live?: Simulation | null): string | null {
const sim = live ?? plan.simulation
if (!sim) return null
const where = `${sim.provider} at block ${sim.blockNumber}`
if (!sim.success) {
const which = sim.failedCall ? `call ${sim.failedCall}` : 'the batch'
return `${where} — ${which} reverted${sim.revertReason ? `: ${sim.revertReason}` : ''}`
}
return `Simulated by ${where}. A prediction, not a guarantee.`
}
/**
* Will it execute? One mark, for the card.
*
* The card used to carry the whole revert sentence, which is a paragraph of
* chain vocabulary in the middle of a decision a person makes in seconds.
* They need to know that something is wrong, not what; the reason is in the
* advanced panel, where somebody who wants it will look. Null when nothing
* has run, because "no simulation" is not a verdict either way.
*/
export interface Executability {
ok: boolean
label: string
}
export function executability(plan: Plan, live?: Simulation | null): Executability | null {
const sim = live ?? plan.simulation
if (!sim) return null
return sim.success ? { ok: true, label: 'Executable' } : { ok: false, label: 'May fail' }
}
/**
* The banner a fresh simulation earns when it disagrees with the plan.
*
* The plan was built against a block that has since passed. A browser run
* that now reverts is the most useful thing the page can tell somebody, and
* the reason signing is taken away: whatever the service concluded minutes
* ago, this will not execute.
*/
export function liveRefusal(live: Simulation | null): string | null {
if (!live || live.success) return null
const which = live.failedCall ? `Call ${live.failedCall}` : 'This batch'
return `${which} reverts against the chain as it is right now${live.revertReason ? `: ${live.revertReason}` : ''}.`
}
export interface Recipient {
address: string
name: string | null
}
export function recipientOf(plan: Plan): Recipient | null {
if (plan.intent.kind !== 'transfer') return null
return { address: addressOf(plan.intent.to), name: plan.intent.toName ?? null }
}
export interface Fact {
label: string
value: string
detail?: string
mono?: boolean
}
/**
* What would be left standing if the person signed the approval and stopped.
*
* Named in the asset's own words, because "an allowance could remain" is not
* a thing anybody can weigh and "500 USDC to 0x1231…4eae" is. Null when the
* plan carries no approval, which is when the question does not arise.
*/
export interface StandingApproval {
spender: string
amount: string
symbol: string
unlimited: boolean
}
export function standingApproval(plan: Plan): StandingApproval | null {
const grant = approvals(plan)[0]
if (!grant) return null
const spent = sourceAssetIdOf(plan)
const words = spent === null ? null : assetWords(plan, spent)
return {
spender: grant.spender,
amount: grant.unlimited ? 'unlimited' : words ? formatAmount(grant.amount, words.decimals) : grant.amount,
symbol: words?.symbol ?? '',
unlimited: grant.unlimited,
}
}
/** The asset a plan spends. Mirrors the service's own reading. */
export function sourceAssetIdOf(plan: Plan): string | null {
if (plan.intent.kind === 'transfer') return plan.intent.asset
if (plan.intent.kind === 'swap' || plan.intent.kind === 'bridge') return plan.intent.from
return null
}
/** The approvals a plan carries, for the callout. */
export function approvals(plan: Plan): { spender: string; amount: string; unlimited: boolean }[] {
return plan.decodedActions.flatMap((a) =>
a.approval ? [{ spender: a.approval.spender, amount: a.approval.amount, unlimited: a.approval.amount === 'unlimited' }] : [],
)
}
/** Warnings worth a banner: anything above info. */
export function bannerWarnings(plan: Plan): PlanWarning[] {
return plan.humanPlan.warnings.filter((w) => w.severity !== 'info')
}
export interface DecodedRow {
signature: string
verified: boolean
contractName: string | null
isContract: boolean
raw: { to: string; value: string; data: string }
}
/** One row per call, pairing the call with what the service decoded it as. */
export function decodedRows(plan: Plan): DecodedRow[] {
if (plan.outcome.type !== 'calls') return []
return plan.outcome.calls.map((call, i) => {
const action: DecodedAction | undefined = plan.decodedActions[i]
const args = action?.args.map((a) => shortValue(a.type, a.value)).join(', ') ?? ''
const name = action?.function ?? 'unknown'
const signature = name === 'nativeTransfer()' ? 'send value' : name === 'unknown' ? 'unknown calldata' : `${name.replace(/\(.*$/, '')}(${args})`
return {
signature,
verified: action?.verified ?? false,
contractName: action?.contractName ?? null,
isContract: action?.isContract ?? true,
raw: { to: addressOf(call.to), value: call.value, data: call.data },
}
})
}
function shortValue(type: string, value: string): string {
if (type === 'address') return truncateAddress(value)
return value.length > 24 ? `${value.slice(0, 12)}…` : value
}
/** "Verified" when every contract the plan touches has verified source. */
export function verificationSummary(plan: Plan): { allVerified: boolean; contracts: number } {
const contracts = plan.decodedActions.filter((a) => a.isContract)
return { allVerified: contracts.every((a) => a.verified), contracts: contracts.length }
}
/** How long until the plan expires, in words. Empty once it has. */
export function countdown(expiresAt: string, now = Date.now()): string {
const left = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000))
if (left === 0) return ''
const m = Math.floor(left / 60)
const s = left % 60
return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}s`
}
/** Who prepared it, for the details. */
export function preparedBy(plan: Plan): string {
return plan.createdVia === 'agent' ? 'An agent, over MCP' : 'You, in Ottopus'
}
/**
* The rows under the amount, cut to what a person deciding actually needs.
*
* The wallet and the network moved into one line beside the amount, and the
* expiry into the header, so what is left is the fee and anything the person
* was told about the request. Everything else — hashes, provenance, decoded
* arguments — belongs in the advanced panel, where somebody who wants it
* knows to look.
*/
export function keyFacts(plan: Plan): Fact[] {
const rows: Fact[] = []
if (plan.humanPlan.feesUsd && plan.humanPlan.feesUsd !== 'unknown') {
rows.push({ label: 'Network fee', value: `$${plan.humanPlan.feesUsd}`, detail: 'estimated', mono: true })
} else {
rows.push({ label: 'Network fee', value: 'Shown by your wallet' })
}
if (plan.intent.kind === 'transfer' && plan.intent.note) {
rows.push({ label: 'Note', value: plan.intent.note })
}
return rows
}
/**
* What the wallet will be asked to do, one row per call.
*
* The panel used to say "One signature in your wallet" and draw a single
* numbered row, whatever the plan held — so a swap's approval was invisible
* until the wallet opened twice. A plan's calls are the steps, and a person
* about to sign should be able to count them.
*/
export interface PlanStep {
/** 1-based, as a person counts. */
index: number
label: string
/** The spender, the recipient, the contract. Shown small, beside the label. */
detail?: string
}
/**
* Prefixes this page writes into `humanPlan.steps` itself, so the route's own
* words can be told apart from the sentences added around them.
*
* Reading our own format is the weak part of this: the route's hops would be
* better as their own field on `humanPlan`, the way `assets` is. Sniffing
* costs nothing and works for every plan already stored, which a new field
* would not.
*/
const ADDED_STEP = /^(At least |Note from the request:|[a-z]+ estimates |[a-z]+ does not estimate )/
export function planSteps(plan: Plan): PlanStep[] {
if (plan.outcome.type !== 'calls') return []
const calls = plan.outcome.calls
const routeWords = plan.humanPlan.steps.filter((step) => !ADDED_STEP.test(step))
return calls.map((call, i) => {
const action = plan.decodedActions[i]
const index = i + 1
if (action?.approval) {
const spent = sourceAssetIdOf(plan)
const words = spent === null ? null : assetWords(plan, spent)
const amount =
action.approval.amount === 'unlimited'
? 'unlimited'
: words
? `${formatAmount(action.approval.amount, words.decimals)} ${words.symbol}`
: action.approval.amount
return {
index,
label: `Approve ${amount}`,
detail: `for ${truncateAddress(addressOf(action.approval.spender))}`,
}
}
// A move we can read is described by what it does, not by its signature.
const moved = readableMove(plan, call, action)
if (moved) return { index, ...moved }
// The last call is the one the route's words describe: one call executes
// the whole route, however many hops the provider listed.
if (i === calls.length - 1 && routeWords.length > 0) {
return { index, label: routeWords.join(', then ') }
}
return {
index,
label: action?.function === 'unknown' || !action ? 'A call this page could not read' : action.function,
detail: `on ${truncateAddress(addressOf(call.to))}`,
}
})
}
/**
* A transfer, in words, when the decoder could read it.
*
* Printing `transfer(address,uint256)` at somebody about to sign is a worse
* answer than the page already has: the arguments are right there, and being
* able to say what a call does is the whole point of decoding it.
*/
function readableMove(
plan: Plan,
call: { to: string; value: string },
action: DecodedAction | undefined,
): { label: string; detail?: string } | null {
const spent = sourceAssetIdOf(plan)
const words = spent === null ? null : assetWords(plan, spent)
if (action?.source === 'native' && call.value !== '0') {
const amount = words ? `${formatAmount(call.value, words.decimals)} ${words.symbol}` : `${call.value} wei`
return { label: `Send ${amount}`, detail: `to ${truncateAddress(addressOf(call.to))}` }
}
if (action?.function !== 'transfer(address,uint256)') return null
const to = action.args.find((a) => a.type === 'address')?.value
const raw = action.args.find((a) => a.type.startsWith('uint'))?.value
if (!to || !raw) return null
const amount = words ? `${formatAmount(raw, words.decimals)} ${words.symbol}` : raw
return { label: `Send ${amount}`, detail: `to ${truncateAddress(to)}` }
}