@lukso/transaction-view-headless 0.4.3 → 0.4.4-dev.c5d67d4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +32 -30
- package/.turbo/turbo-typecheck.log +1 -1
- package/ARG_ACCESS_PATTERN.md +265 -0
- package/CHANGELOG.md +14 -0
- package/CONTEXT_VARIANT_GUIDE.md +4 -4
- package/EVENT_SYSTEM_SUMMARY.md +601 -0
- package/HEADER_EVENTS_GUIDE.md +291 -0
- package/RECOMPOSITION_REFERENCE.md +303 -0
- package/TRANSACTION_MODIFICATION_GUIDE.md +927 -0
- package/dist/composables/index.cjs +1 -1
- package/dist/composables/index.cjs.map +1 -1
- package/dist/composables/index.d.cts +3 -3
- package/dist/composables/index.d.ts +3 -3
- package/dist/composables/index.js +1 -1
- package/dist/composables/index.js.map +1 -1
- package/dist/{index-CSDjhiKV.d.cts → index-BqL7xdDl.d.cts} +2 -2
- package/dist/{index-CMLU1hKL.d.ts → index-CNUHqRdk.d.ts} +2 -2
- package/dist/index.cjs +60 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +55 -1
- package/dist/index.js.map +1 -1
- package/dist/registry/index.d.cts +2 -2
- package/dist/registry/index.d.ts +2 -2
- package/dist/types/index.cjs +68 -0
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.d.cts +229 -169
- package/dist/types/index.d.ts +229 -169
- package/dist/types/index.js +55 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utils/index.d.cts +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/{view-loader-dD86QAlQ.d.cts → view-loader--mEEI8YY.d.ts} +1 -1
- package/dist/{view-loader-ii8AxiQR.d.ts → view-loader-D7ApWfdN.d.cts} +1 -1
- package/dist/view-matching-C9G3z8lQ.d.cts +203 -0
- package/dist/view-matching-C9G3z8lQ.d.ts +203 -0
- package/package.json +3 -3
- package/src/composables/use-transaction-playback.ts +8 -5
- package/src/types/index.ts +14 -0
- package/src/types/view-events.ts +322 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
# Transaction Modification Guide
|
|
2
|
+
|
|
3
|
+
This guide explains how transaction views can modify transaction data through an event-based system, including handling of wrapped transactions and batches.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Transaction views often need to allow users to modify transaction parameters before submission:
|
|
8
|
+
|
|
9
|
+
- **Force Transfer Checkbox**: Enable force flag for LSP7 transfers to EOA addresses
|
|
10
|
+
- **Slippage Tolerance**: Adjust acceptable slippage for DEX swaps
|
|
11
|
+
- **Allowance Amount**: Modify token approval amounts
|
|
12
|
+
- **Gas Settings**: Change gas limits or priorities
|
|
13
|
+
|
|
14
|
+
These modifications must work correctly even within wrapped transactions (Execute calls) and batch operations.
|
|
15
|
+
|
|
16
|
+
## Architecture Overview
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
┌─────────────────────────────────────────┐
|
|
20
|
+
│ View Component (LSP7 Transfer) │
|
|
21
|
+
│ - User clicks "Force Transfer" checkbox│
|
|
22
|
+
│ - View re-encodes local transaction │
|
|
23
|
+
│ - Emits modification event │
|
|
24
|
+
│ { data: "0xnewdata...", index: 2 } │
|
|
25
|
+
└──────────────┬──────────────────────────┘
|
|
26
|
+
│ bubbles up
|
|
27
|
+
▼
|
|
28
|
+
┌─────────────────────────────────────────┐
|
|
29
|
+
│ <transaction-playback> (index 2) │
|
|
30
|
+
│ - Validates modification │
|
|
31
|
+
│ - Re-decodes to verify │
|
|
32
|
+
│ - Adds transaction path info │
|
|
33
|
+
│ - Re-emits with context │
|
|
34
|
+
└──────────────┬──────────────────────────┘
|
|
35
|
+
│ bubbles up
|
|
36
|
+
▼
|
|
37
|
+
┌─────────────────────────────────────────┐
|
|
38
|
+
│ Top-Level Container │
|
|
39
|
+
│ - Navigates to correct transaction │
|
|
40
|
+
│ - Applies modification │
|
|
41
|
+
│ - Re-wraps if needed (Execute/Batch) │
|
|
42
|
+
│ - Re-decodes entire tree │
|
|
43
|
+
│ - Updates UI │
|
|
44
|
+
└─────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## The Wrapping Challenge
|
|
48
|
+
|
|
49
|
+
Consider this transaction structure:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
ExecuteRelayCall (top level - signed by controller)
|
|
53
|
+
└─ signature: 0x...
|
|
54
|
+
└─ data: "0xaabbcc..." (encoded execute)
|
|
55
|
+
└─ Decoded: Execute
|
|
56
|
+
├─ target: 0xProfile
|
|
57
|
+
├─ data: "0x112233..." (encoded executeBatch)
|
|
58
|
+
└─ Decoded: ExecuteBatch
|
|
59
|
+
├─ Child[0]: Execute
|
|
60
|
+
│ ├─ target: 0xTokenContract
|
|
61
|
+
│ ├─ data: "0x445566..." (encoded transfer)
|
|
62
|
+
│ └─ Decoded: transfer(from, to, amount, force=false, data)
|
|
63
|
+
│
|
|
64
|
+
└─ Child[1]: SetData
|
|
65
|
+
└─ setData(key, value)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
When the transfer view modifies `force` to `true`:
|
|
69
|
+
|
|
70
|
+
1. ✅ View encodes **local** transaction data: `transfer(..., force=true, ...)`
|
|
71
|
+
2. ✅ Parent **recomposes** the original wrappers:
|
|
72
|
+
- Insert new transfer data into Execute wrapper (preserving target, value)
|
|
73
|
+
- Recompose ExecuteBatch with updated Execute (preserving other children)
|
|
74
|
+
- Recompose ExecuteRelayCall with updated ExecuteBatch (preserving signature, nonce)
|
|
75
|
+
3. ✅ Parent re-decodes entire transaction to verify and update UI
|
|
76
|
+
|
|
77
|
+
**Key Insights**:
|
|
78
|
+
- Views only handle their own level
|
|
79
|
+
- Parents **recompose** original wrappers (don't create new ones)
|
|
80
|
+
- All original wrapper arguments are preserved (signatures, nonces, targets, etc.)
|
|
81
|
+
|
|
82
|
+
## Type Definitions
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface TransactionModification {
|
|
86
|
+
/**
|
|
87
|
+
* The modified data for THIS transaction level
|
|
88
|
+
* NOT the top-level transaction data
|
|
89
|
+
*
|
|
90
|
+
* This is the encoded function call for the current transaction,
|
|
91
|
+
* not wrapped in Execute or Batch calls.
|
|
92
|
+
*/
|
|
93
|
+
data: `0x${string}`
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The playback index this modification applies to
|
|
97
|
+
*
|
|
98
|
+
* - 0 = batch summary (modifies batch itself)
|
|
99
|
+
* - 1 = first transaction (or single tx)
|
|
100
|
+
* - 2 = second child transaction
|
|
101
|
+
* - etc.
|
|
102
|
+
*
|
|
103
|
+
* This matches the `index` prop of <transaction-playback>
|
|
104
|
+
*/
|
|
105
|
+
transactionIndex: number
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Human-readable explanation of why this modification occurred
|
|
109
|
+
*
|
|
110
|
+
* @example "User enabled force transfer to EOA"
|
|
111
|
+
* @example "Slippage tolerance increased to 2%"
|
|
112
|
+
*/
|
|
113
|
+
reason: string
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether this modification was triggered by user interaction
|
|
117
|
+
*
|
|
118
|
+
* - true: User clicked checkbox, changed input, etc.
|
|
119
|
+
* - false: Automatic adjustment by view logic
|
|
120
|
+
*/
|
|
121
|
+
userInitiated: boolean
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Optional structured summary for quick display/logging
|
|
125
|
+
*/
|
|
126
|
+
summary?: {
|
|
127
|
+
/** Name of the field that changed */
|
|
128
|
+
field: string
|
|
129
|
+
|
|
130
|
+
/** Human-readable old value */
|
|
131
|
+
oldValue: string
|
|
132
|
+
|
|
133
|
+
/** Human-readable new value */
|
|
134
|
+
newValue: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Optional validation information
|
|
139
|
+
*/
|
|
140
|
+
validation?: {
|
|
141
|
+
isValid: boolean
|
|
142
|
+
error?: string
|
|
143
|
+
warnings?: string[]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extended modification info added by transaction-playback
|
|
149
|
+
* This is what the top-level container receives
|
|
150
|
+
*/
|
|
151
|
+
interface TransactionModificationWithContext extends TransactionModification {
|
|
152
|
+
/**
|
|
153
|
+
* Path from root transaction to this transaction
|
|
154
|
+
*
|
|
155
|
+
* @example [1] - Simple transaction at index 1
|
|
156
|
+
* @example [0, 2] - Batch at index 0, child at index 2
|
|
157
|
+
* @example [0, 1, 0] - Nested batch: batch[0].children[1].children[0]
|
|
158
|
+
*/
|
|
159
|
+
transactionPath: number[]
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The full local transaction object with modified data
|
|
163
|
+
*/
|
|
164
|
+
localTransaction: DecoderResult
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Re-decoded result for verification
|
|
168
|
+
*/
|
|
169
|
+
decodedResult: DecoderResult
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## View Implementation
|
|
174
|
+
|
|
175
|
+
### Basic Example: Force Transfer Checkbox
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { dispatchTransactionModification, getArgByName } from '@lukso/transaction-view-headless'
|
|
179
|
+
import { encodeFunctionData } from 'viem'
|
|
180
|
+
|
|
181
|
+
@customElement('lsp7-transfer-view')
|
|
182
|
+
export class LSP7TransferView extends CoreLitElement {
|
|
183
|
+
@property({ type: Object }) transaction!: DecoderResult
|
|
184
|
+
@property({ type: String }) context?: ViewContext
|
|
185
|
+
@property({ type: Number }) index!: number // From transaction-playback
|
|
186
|
+
|
|
187
|
+
@state()
|
|
188
|
+
private forceTransfer = false
|
|
189
|
+
|
|
190
|
+
connectedCallback() {
|
|
191
|
+
super.connectedCallback()
|
|
192
|
+
// Initialize from transaction
|
|
193
|
+
this.forceTransfer = (getArgByName(this.transaction.args, 'force') as boolean) ?? false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private handleForceChange(checked: boolean) {
|
|
197
|
+
this.forceTransfer = checked
|
|
198
|
+
|
|
199
|
+
// Re-encode with new force value
|
|
200
|
+
const newData = encodeFunctionData({
|
|
201
|
+
abi: LSP7_ABI,
|
|
202
|
+
functionName: 'transfer',
|
|
203
|
+
args: [
|
|
204
|
+
getArgByName(this.transaction.args, 'from'),
|
|
205
|
+
getArgByName(this.transaction.args, 'to'),
|
|
206
|
+
getArgByName(this.transaction.args, 'amount'),
|
|
207
|
+
checked, // <-- modified
|
|
208
|
+
getArgByName(this.transaction.args, 'data')
|
|
209
|
+
]
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Emit modification event
|
|
213
|
+
dispatchTransactionModification(this, {
|
|
214
|
+
data: newData,
|
|
215
|
+
transactionIndex: this.index,
|
|
216
|
+
reason: checked
|
|
217
|
+
? 'User enabled force transfer to EOA'
|
|
218
|
+
: 'User disabled force transfer',
|
|
219
|
+
userInitiated: true,
|
|
220
|
+
summary: {
|
|
221
|
+
field: 'force',
|
|
222
|
+
oldValue: String(!checked),
|
|
223
|
+
newValue: String(checked)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
render() {
|
|
229
|
+
// Context-aware rendering
|
|
230
|
+
if (this.context === 'approval') {
|
|
231
|
+
// Show interactive checkbox
|
|
232
|
+
return html`
|
|
233
|
+
<div class="transfer-content">
|
|
234
|
+
<!-- Transfer details -->
|
|
235
|
+
|
|
236
|
+
<label class="flex items-center gap-2 mt-4">
|
|
237
|
+
<input
|
|
238
|
+
type="checkbox"
|
|
239
|
+
.checked=${this.forceTransfer}
|
|
240
|
+
@change=${(e) => this.handleForceChange(e.target.checked)}
|
|
241
|
+
/>
|
|
242
|
+
<span>Force transfer (required for EOA recipients)</span>
|
|
243
|
+
</label>
|
|
244
|
+
</div>
|
|
245
|
+
`
|
|
246
|
+
} else if (this.context === 'feed') {
|
|
247
|
+
// Show read-only status
|
|
248
|
+
return html`
|
|
249
|
+
<div class="transfer-summary">
|
|
250
|
+
<!-- Compact transfer info -->
|
|
251
|
+
${this.forceTransfer ? html`
|
|
252
|
+
<div class="text-12 text-orange-600">⚠️ Forced transfer</div>
|
|
253
|
+
` : ''}
|
|
254
|
+
</div>
|
|
255
|
+
`
|
|
256
|
+
} else {
|
|
257
|
+
// Detail view
|
|
258
|
+
return html`
|
|
259
|
+
<div class="transfer-details">
|
|
260
|
+
<!-- Full transfer info -->
|
|
261
|
+
<div class="mt-4">
|
|
262
|
+
<strong>Force:</strong> ${this.forceTransfer ? 'Yes' : 'No'}
|
|
263
|
+
${this.forceTransfer ? html`
|
|
264
|
+
<div class="text-12 text-neutral-60">
|
|
265
|
+
This transfer will succeed even if recipient is an EOA
|
|
266
|
+
</div>
|
|
267
|
+
` : ''}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
`
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Advanced Example: Slippage Tolerance
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
@customElement('swap-view')
|
|
280
|
+
export class SwapView extends CoreLitElement {
|
|
281
|
+
@property({ type: Object }) transaction!: DecoderResult
|
|
282
|
+
@property({ type: Number }) index!: number
|
|
283
|
+
|
|
284
|
+
@state()
|
|
285
|
+
private slippageBps = 50 // 0.5% default
|
|
286
|
+
|
|
287
|
+
private handleSlippageChange(bps: number) {
|
|
288
|
+
this.slippageBps = bps
|
|
289
|
+
|
|
290
|
+
// Recalculate minimum output with new slippage
|
|
291
|
+
const minOutput = this.calculateMinOutput(
|
|
292
|
+
getArgByName(this.transaction.args, 'amountOut') as bigint,
|
|
293
|
+
bps
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
// Re-encode swap with new minimum
|
|
297
|
+
const newData = encodeFunctionData({
|
|
298
|
+
abi: SWAP_ABI,
|
|
299
|
+
functionName: 'swap',
|
|
300
|
+
args: [
|
|
301
|
+
getArgByName(this.transaction.args, 'tokenIn'),
|
|
302
|
+
getArgByName(this.transaction.args, 'tokenOut'),
|
|
303
|
+
getArgByName(this.transaction.args, 'amountIn'),
|
|
304
|
+
minOutput, // <-- modified
|
|
305
|
+
getArgByName(this.transaction.args, 'recipient')
|
|
306
|
+
]
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
dispatchTransactionModification(this, {
|
|
310
|
+
data: newData,
|
|
311
|
+
transactionIndex: this.index,
|
|
312
|
+
reason: `User adjusted slippage tolerance to ${bps / 100}%`,
|
|
313
|
+
userInitiated: true,
|
|
314
|
+
summary: {
|
|
315
|
+
field: 'slippage',
|
|
316
|
+
oldValue: `${this.slippageBps / 100}%`,
|
|
317
|
+
newValue: `${bps / 100}%`
|
|
318
|
+
},
|
|
319
|
+
validation: {
|
|
320
|
+
isValid: bps <= 1000, // Max 10%
|
|
321
|
+
warnings: bps > 500 ? ['High slippage may result in unfavorable trade'] : []
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
render() {
|
|
327
|
+
return html`
|
|
328
|
+
<div>
|
|
329
|
+
<label>
|
|
330
|
+
Slippage Tolerance: ${this.slippageBps / 100}%
|
|
331
|
+
<input
|
|
332
|
+
type="range"
|
|
333
|
+
min="10"
|
|
334
|
+
max="1000"
|
|
335
|
+
step="10"
|
|
336
|
+
.value=${String(this.slippageBps)}
|
|
337
|
+
@input=${(e) => this.handleSlippageChange(Number(e.target.value))}
|
|
338
|
+
/>
|
|
339
|
+
</label>
|
|
340
|
+
</div>
|
|
341
|
+
`
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Parent Container Implementation
|
|
347
|
+
|
|
348
|
+
### In transaction-playback Component
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// Already implemented in transaction-playback.ts
|
|
352
|
+
connectedCallback(): void {
|
|
353
|
+
super.connectedCallback()
|
|
354
|
+
this.addEventListener(
|
|
355
|
+
TransactionViewEvents.TRANSACTION_MODIFICATION,
|
|
356
|
+
this.handleTransactionModification as EventListener
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private handleTransactionModification = (event: Event): void => {
|
|
361
|
+
if (!isTransactionModificationEvent(event)) return
|
|
362
|
+
|
|
363
|
+
const mod = event.detail
|
|
364
|
+
|
|
365
|
+
// Verify this modification is for our transaction
|
|
366
|
+
if (mod.transactionIndex !== this.index) {
|
|
367
|
+
console.warn(`Modification index mismatch: expected ${this.index}, got ${mod.transactionIndex}`)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Create modified local transaction
|
|
372
|
+
const modifiedLocal: DecoderResult = {
|
|
373
|
+
...this.currentResult.transaction,
|
|
374
|
+
data: mod.data
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Re-decode to verify
|
|
378
|
+
const reDecoded = await decoder.decode(modifiedLocal)
|
|
379
|
+
|
|
380
|
+
// Add context and re-emit for top-level container
|
|
381
|
+
this.dispatchEvent(
|
|
382
|
+
new CustomEvent(TransactionViewEvents.TRANSACTION_MODIFICATION, {
|
|
383
|
+
bubbles: true,
|
|
384
|
+
composed: true,
|
|
385
|
+
detail: {
|
|
386
|
+
...mod,
|
|
387
|
+
transactionPath: this.getTransactionPath(),
|
|
388
|
+
localTransaction: modifiedLocal,
|
|
389
|
+
decodedResult: reDecoded
|
|
390
|
+
} as TransactionModificationWithContext
|
|
391
|
+
})
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private getTransactionPath(): number[] {
|
|
396
|
+
// Get path from root to current transaction
|
|
397
|
+
return this.playbackState.getTransactionPath(this.index)
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### In Top-Level Container (Wallet/App)
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
class WalletApprovalScreen extends LitElement {
|
|
405
|
+
@state()
|
|
406
|
+
private originalTransaction: DecoderResult
|
|
407
|
+
|
|
408
|
+
@state()
|
|
409
|
+
private modifiedTransaction?: DecoderResult
|
|
410
|
+
|
|
411
|
+
@state()
|
|
412
|
+
private modifications: TransactionModificationWithContext[] = []
|
|
413
|
+
|
|
414
|
+
render() {
|
|
415
|
+
return html`
|
|
416
|
+
<div class="approval-screen">
|
|
417
|
+
<h2>Approve Transaction</h2>
|
|
418
|
+
|
|
419
|
+
<!-- Show modification summary if any -->
|
|
420
|
+
${this.modifications.length > 0 ? html`
|
|
421
|
+
<div class="modifications-summary">
|
|
422
|
+
<h3>User Modifications:</h3>
|
|
423
|
+
${this.modifications.map(mod => html`
|
|
424
|
+
<div class="modification-item">
|
|
425
|
+
<span class="icon">✏️</span>
|
|
426
|
+
<span>${mod.reason}</span>
|
|
427
|
+
${mod.summary ? html`
|
|
428
|
+
<span class="change">
|
|
429
|
+
${mod.summary.field}:
|
|
430
|
+
${mod.summary.oldValue} → ${mod.summary.newValue}
|
|
431
|
+
</span>
|
|
432
|
+
` : ''}
|
|
433
|
+
</div>
|
|
434
|
+
`)}
|
|
435
|
+
</div>
|
|
436
|
+
` : ''}
|
|
437
|
+
|
|
438
|
+
<!-- Transaction view (will re-render with modifications) -->
|
|
439
|
+
<transaction-playback
|
|
440
|
+
.transaction=${this.modifiedTransaction ?? this.originalTransaction}
|
|
441
|
+
index="1"
|
|
442
|
+
context="approval"
|
|
443
|
+
@transaction-modification=${this.handleModification}
|
|
444
|
+
></transaction-playback>
|
|
445
|
+
|
|
446
|
+
<div class="actions">
|
|
447
|
+
<button @click=${this.reject}>Reject</button>
|
|
448
|
+
<button @click=${this.approve}>Approve</button>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
`
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async handleModification(event: CustomEvent) {
|
|
455
|
+
const mod: TransactionModificationWithContext = event.detail
|
|
456
|
+
|
|
457
|
+
// Store modification
|
|
458
|
+
this.modifications.push(mod)
|
|
459
|
+
|
|
460
|
+
// Recompose the original transaction with the modification
|
|
461
|
+
const recomposed = await this.recomposeTransaction(
|
|
462
|
+
this.originalTransaction,
|
|
463
|
+
mod
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
// Update state - this triggers re-render
|
|
467
|
+
this.modifiedTransaction = recomposed
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Recompose the original transaction with a modification
|
|
472
|
+
*
|
|
473
|
+
* Takes the original transaction structure and inserts the modified data
|
|
474
|
+
* at the correct location, preserving all original wrappers (Execute,
|
|
475
|
+
* ExecuteBatch, ExecuteRelayCall, etc.)
|
|
476
|
+
*
|
|
477
|
+
* Example:
|
|
478
|
+
* Original: executeRelayCall(execute(executeBatch([transfer, setData])))
|
|
479
|
+
* Modified: executeRelayCall(execute(executeBatch([transfer*, setData])))
|
|
480
|
+
* (* = transfer with force=true)
|
|
481
|
+
*/
|
|
482
|
+
private async recomposeTransaction(
|
|
483
|
+
originalTx: DecoderResult,
|
|
484
|
+
mod: TransactionModificationWithContext
|
|
485
|
+
): Promise<DecoderResult> {
|
|
486
|
+
const { transactionPath, data } = mod
|
|
487
|
+
|
|
488
|
+
// Clone the original transaction to preserve structure
|
|
489
|
+
const recomposed = structuredClone(originalTx)
|
|
490
|
+
|
|
491
|
+
if (transactionPath.length === 1) {
|
|
492
|
+
// Simple transaction - just replace data
|
|
493
|
+
recomposed.data = data
|
|
494
|
+
} else {
|
|
495
|
+
// Complex transaction - navigate and recompose wrappers
|
|
496
|
+
await this.recomposeAtPath(recomposed, transactionPath, data)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Re-decode entire transaction tree to verify and get fresh DecoderResult
|
|
500
|
+
const reDecoded = await decoder.decode(recomposed)
|
|
501
|
+
|
|
502
|
+
return reDecoded
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Navigate to a transaction path and recompose wrappers bottom-up
|
|
507
|
+
*
|
|
508
|
+
* Example path [0, 1, 2] means:
|
|
509
|
+
* - Navigate to batch[0].children[1].children[2]
|
|
510
|
+
* - Replace that transaction's data
|
|
511
|
+
* - Recompose batch[0].children[1] wrapper with updated child
|
|
512
|
+
* - Recompose batch[0] wrapper with updated child
|
|
513
|
+
* - Recompose root with updated batch
|
|
514
|
+
*/
|
|
515
|
+
private async recomposeAtPath(
|
|
516
|
+
root: any,
|
|
517
|
+
path: number[],
|
|
518
|
+
newData: string
|
|
519
|
+
): Promise<void> {
|
|
520
|
+
// Navigate to the target transaction
|
|
521
|
+
let current = root
|
|
522
|
+
const parents: any[] = [root]
|
|
523
|
+
|
|
524
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
525
|
+
const index = path[i]
|
|
526
|
+
if (current.children?.[index]) {
|
|
527
|
+
current = current.children[index]
|
|
528
|
+
parents.push(current)
|
|
529
|
+
} else {
|
|
530
|
+
throw new Error(`Invalid transaction path: ${path}`)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Apply modification to the target
|
|
535
|
+
const targetIndex = path[path.length - 1]
|
|
536
|
+
if (!current.children?.[targetIndex]) {
|
|
537
|
+
throw new Error(`Invalid transaction path: ${path}`)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
current.children[targetIndex].data = newData
|
|
541
|
+
|
|
542
|
+
// Recompose wrappers bottom-up
|
|
543
|
+
// Start from the deepest parent and work up to root
|
|
544
|
+
for (let i = parents.length - 1; i >= 0; i--) {
|
|
545
|
+
const parent = parents[i]
|
|
546
|
+
if (parent.children && parent.functionName) {
|
|
547
|
+
parent.data = await this.recomposeWrapper(parent)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Recompose a wrapper transaction (Execute, ExecuteBatch, ExecuteRelayCall, etc.)
|
|
554
|
+
*
|
|
555
|
+
* Takes the original wrapper and re-encodes it with updated child data,
|
|
556
|
+
* preserving all original arguments (operationType, target, value, signature, etc.)
|
|
557
|
+
*/
|
|
558
|
+
private async recomposeWrapper(wrapper: any): Promise<string> {
|
|
559
|
+
const { functionName, args, children } = wrapper
|
|
560
|
+
|
|
561
|
+
// Recompose based on wrapper type
|
|
562
|
+
switch (functionName) {
|
|
563
|
+
case 'execute':
|
|
564
|
+
return encodeFunctionData({
|
|
565
|
+
abi: UP_ABI,
|
|
566
|
+
functionName: 'execute',
|
|
567
|
+
args: [
|
|
568
|
+
getArgByName(args, 'operationType') as number,
|
|
569
|
+
getArgByName(args, 'target') as string,
|
|
570
|
+
getArgByName(args, 'value') as bigint,
|
|
571
|
+
children[0].data // <-- updated child data
|
|
572
|
+
]
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
case 'executeBatch':
|
|
576
|
+
return encodeFunctionData({
|
|
577
|
+
abi: UP_ABI,
|
|
578
|
+
functionName: 'executeBatch',
|
|
579
|
+
args: [
|
|
580
|
+
getArgByName(args, 'operationTypes') as number[],
|
|
581
|
+
getArgByName(args, 'targets') as string[],
|
|
582
|
+
getArgByName(args, 'values') as bigint[],
|
|
583
|
+
children.map((child: any) => child.data) // <-- updated children data
|
|
584
|
+
]
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
case 'executeRelayCall':
|
|
588
|
+
return encodeFunctionData({
|
|
589
|
+
abi: KEY_MANAGER_ABI,
|
|
590
|
+
functionName: 'executeRelayCall',
|
|
591
|
+
args: [
|
|
592
|
+
getArgByName(args, 'signature') as string,
|
|
593
|
+
getArgByName(args, 'nonce') as bigint,
|
|
594
|
+
getArgByName(args, 'validityTimestamps') as bigint,
|
|
595
|
+
children[0].data // <-- updated execute/executeBatch data
|
|
596
|
+
]
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
default:
|
|
600
|
+
throw new Error(`Unknown wrapper function: ${functionName}`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async approve() {
|
|
605
|
+
// Submit the recomposed transaction (or original if no modifications)
|
|
606
|
+
const finalTx = this.modifiedTransaction ?? this.originalTransaction
|
|
607
|
+
|
|
608
|
+
// Log modifications for audit trail
|
|
609
|
+
if (this.modifications.length > 0) {
|
|
610
|
+
console.log('Transaction modifications:', this.modifications)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Send transaction
|
|
614
|
+
await this.wallet.sendTransaction(finalTx)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
## Helper Functions
|
|
620
|
+
|
|
621
|
+
### dispatchTransactionModification
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
/**
|
|
625
|
+
* Helper function to dispatch transaction modification event
|
|
626
|
+
* Use this in view components to emit modifications
|
|
627
|
+
*/
|
|
628
|
+
export function dispatchTransactionModification(
|
|
629
|
+
element: HTMLElement,
|
|
630
|
+
modification: TransactionModification
|
|
631
|
+
): void {
|
|
632
|
+
element.dispatchEvent(
|
|
633
|
+
new CustomEvent(TransactionViewEvents.TRANSACTION_MODIFICATION, {
|
|
634
|
+
bubbles: true,
|
|
635
|
+
composed: true,
|
|
636
|
+
detail: modification,
|
|
637
|
+
})
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### isTransactionModificationEvent
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
/**
|
|
646
|
+
* Type guard to check if an event is a TransactionModificationEvent
|
|
647
|
+
*/
|
|
648
|
+
export function isTransactionModificationEvent(
|
|
649
|
+
event: Event
|
|
650
|
+
): event is CustomEvent<TransactionModification> {
|
|
651
|
+
return (
|
|
652
|
+
event instanceof CustomEvent &&
|
|
653
|
+
event.type === TransactionViewEvents.TRANSACTION_MODIFICATION
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
## Security Considerations
|
|
659
|
+
|
|
660
|
+
### 1. Always Re-decode
|
|
661
|
+
|
|
662
|
+
The parent container **must** re-decode modified data to verify it's valid:
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
// ✅ Good - Re-decode and verify
|
|
666
|
+
const reDecoded = await decoder.decode({ ...tx, data: mod.data })
|
|
667
|
+
if (!reDecoded) {
|
|
668
|
+
throw new Error('Invalid modification - failed to decode')
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### 2. Show Modifications to User
|
|
673
|
+
|
|
674
|
+
Display what changed before submission:
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// ✅ Good - User sees what changed
|
|
678
|
+
<div class="modifications">
|
|
679
|
+
${modifications.map(mod => html`
|
|
680
|
+
<div>${mod.reason}</div>
|
|
681
|
+
${mod.summary ? html`
|
|
682
|
+
<div>${mod.summary.field}: ${mod.summary.oldValue} → ${mod.summary.newValue}</div>
|
|
683
|
+
` : ''}
|
|
684
|
+
`)}
|
|
685
|
+
</div>
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### 3. Validate Modifications
|
|
689
|
+
|
|
690
|
+
Check that modifications make sense:
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
// ✅ Good - Validate before applying
|
|
694
|
+
if (mod.validation && !mod.validation.isValid) {
|
|
695
|
+
showError(mod.validation.error)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (mod.validation?.warnings) {
|
|
700
|
+
showWarnings(mod.validation.warnings)
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### 4. Audit Trail
|
|
705
|
+
|
|
706
|
+
Log all modifications:
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
// ✅ Good - Keep audit trail
|
|
710
|
+
console.log('Transaction modified:', {
|
|
711
|
+
transactionHash: tx.hash,
|
|
712
|
+
modifications: modifications.map(m => ({
|
|
713
|
+
field: m.summary?.field,
|
|
714
|
+
oldValue: m.summary?.oldValue,
|
|
715
|
+
newValue: m.summary?.newValue,
|
|
716
|
+
reason: m.reason,
|
|
717
|
+
userInitiated: m.userInitiated,
|
|
718
|
+
timestamp: Date.now()
|
|
719
|
+
}))
|
|
720
|
+
})
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
## Best Practices
|
|
724
|
+
|
|
725
|
+
### 1. Emit Modifications Immediately
|
|
726
|
+
|
|
727
|
+
Don't batch modifications - emit each change as it happens:
|
|
728
|
+
|
|
729
|
+
```typescript
|
|
730
|
+
// ✅ Good
|
|
731
|
+
private handleChange(value: string) {
|
|
732
|
+
this.emitModification(value)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ❌ Bad - Don't batch
|
|
736
|
+
private handleChange(value: string) {
|
|
737
|
+
this.pendingChanges.push(value)
|
|
738
|
+
// Emit later somehow?
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### 2. Include Transaction Index
|
|
743
|
+
|
|
744
|
+
Always include the correct transaction index:
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
// ✅ Good
|
|
748
|
+
dispatchTransactionModification(this, {
|
|
749
|
+
data: newData,
|
|
750
|
+
transactionIndex: this.index, // From transaction-playback
|
|
751
|
+
// ...
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
// ❌ Bad - Hardcoded or missing
|
|
755
|
+
dispatchTransactionModification(this, {
|
|
756
|
+
data: newData,
|
|
757
|
+
transactionIndex: 1, // Wrong!
|
|
758
|
+
// ...
|
|
759
|
+
})
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
### 3. Provide Good Reasons
|
|
763
|
+
|
|
764
|
+
Write clear, human-readable reasons:
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
// ✅ Good
|
|
768
|
+
reason: 'User enabled force transfer to allow sending to EOA addresses'
|
|
769
|
+
|
|
770
|
+
// ❌ Bad
|
|
771
|
+
reason: 'force=true'
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### 4. Context-Aware UI
|
|
775
|
+
|
|
776
|
+
Show appropriate UI based on context:
|
|
777
|
+
|
|
778
|
+
```typescript
|
|
779
|
+
render() {
|
|
780
|
+
if (this.context === 'approval') {
|
|
781
|
+
// Interactive controls
|
|
782
|
+
return html`<input type="checkbox" @change=${this.handleChange} />`
|
|
783
|
+
} else {
|
|
784
|
+
// Read-only display
|
|
785
|
+
return html`<div>Force: ${this.force}</div>`
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
## Transaction Path Examples
|
|
791
|
+
|
|
792
|
+
### Simple Transaction
|
|
793
|
+
```
|
|
794
|
+
Transaction (index: 1)
|
|
795
|
+
Path: [1]
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
### Execute Wrapper
|
|
799
|
+
```
|
|
800
|
+
Execute (index: 0)
|
|
801
|
+
└─ Transfer (index: 1)
|
|
802
|
+
Path: [0, 1]
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### Batch
|
|
806
|
+
```
|
|
807
|
+
ExecuteBatch (index: 0)
|
|
808
|
+
├─ Transfer (index: 1)
|
|
809
|
+
│ Path: [0, 1]
|
|
810
|
+
└─ SetData (index: 2)
|
|
811
|
+
Path: [0, 2]
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### Nested Batch
|
|
815
|
+
```
|
|
816
|
+
ExecuteBatch (index: 0)
|
|
817
|
+
├─ ExecuteBatch (index: 1)
|
|
818
|
+
│ ├─ Transfer (index: 1, 0)
|
|
819
|
+
│ │ Path: [0, 1, 0]
|
|
820
|
+
│ └─ Transfer (index: 1, 1)
|
|
821
|
+
│ Path: [0, 1, 1]
|
|
822
|
+
└─ SetData (index: 2)
|
|
823
|
+
Path: [0, 2]
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
## Event Constants
|
|
827
|
+
|
|
828
|
+
Use constants instead of hardcoding strings:
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
import { TransactionViewEvents } from '@lukso/transaction-view-headless'
|
|
832
|
+
|
|
833
|
+
// ✅ Good
|
|
834
|
+
this.addEventListener(TransactionViewEvents.TRANSACTION_MODIFICATION, handler)
|
|
835
|
+
|
|
836
|
+
// ❌ Bad
|
|
837
|
+
this.addEventListener('transaction-modification', handler)
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
## Complete Flow Example
|
|
841
|
+
|
|
842
|
+
```
|
|
843
|
+
1. User clicks "Force Transfer" checkbox in LSP7TransferView
|
|
844
|
+
└─> View re-encodes transfer(from, to, amount, true, data)
|
|
845
|
+
└─> Emits { data: "0x...", transactionIndex: 1, reason: "..." }
|
|
846
|
+
|
|
847
|
+
2. <transaction-playback index="1"> receives event
|
|
848
|
+
└─> Validates: mod.transactionIndex === this.index ✓
|
|
849
|
+
└─> Re-decodes: decoder.decode({ ...tx, data: mod.data }) ✓
|
|
850
|
+
└─> Adds path: transactionPath: [0, 1] (Execute wrapper)
|
|
851
|
+
└─> Re-emits with full context
|
|
852
|
+
|
|
853
|
+
3. Top-level container receives event
|
|
854
|
+
└─> Clones original transaction structure
|
|
855
|
+
└─> Navigates to path [0, 1]
|
|
856
|
+
└─> Replaces children[1].data with mod.data
|
|
857
|
+
└─> Recomposes wrappers bottom-up:
|
|
858
|
+
- Recompose execute(target, value, newTransferData)
|
|
859
|
+
- Recompose executeBatch([newExecuteData, setDataData])
|
|
860
|
+
- Recompose executeRelayCall(signature, nonce, newExecuteBatchData)
|
|
861
|
+
└─> Re-decodes entire tree to verify
|
|
862
|
+
└─> Updates state
|
|
863
|
+
└─> UI re-renders with modified transaction
|
|
864
|
+
|
|
865
|
+
4. User sees updated transaction in approval screen
|
|
866
|
+
└─> "Force: false → true" shown in modifications list
|
|
867
|
+
└─> Transaction view shows updated state
|
|
868
|
+
└─> User clicks "Approve"
|
|
869
|
+
└─> Recomposed transaction is submitted (with original signature intact!)
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
## Why Recomposition Matters
|
|
873
|
+
|
|
874
|
+
### ❌ Wrong: Create New Wrappers
|
|
875
|
+
|
|
876
|
+
```typescript
|
|
877
|
+
// DON'T DO THIS
|
|
878
|
+
const newExecute = encodeFunctionData({
|
|
879
|
+
abi: UP_ABI,
|
|
880
|
+
functionName: 'execute',
|
|
881
|
+
args: [0, target, 0, modifiedData] // <-- New wrapper, loses context
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
// This breaks:
|
|
885
|
+
// - Original operationType is lost
|
|
886
|
+
// - Original value is lost
|
|
887
|
+
// - Signature becomes invalid (data changed)
|
|
888
|
+
// - Nonce might be wrong
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
### ✅ Correct: Recompose Original Wrappers
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
// DO THIS
|
|
895
|
+
const recomposed = structuredClone(originalTransaction)
|
|
896
|
+
|
|
897
|
+
// Navigate and update
|
|
898
|
+
recomposed.children[0].children[1].data = modifiedData
|
|
899
|
+
|
|
900
|
+
// Recompose preserving ALL original args
|
|
901
|
+
recomposed.children[0].data = encodeFunctionData({
|
|
902
|
+
abi: UP_ABI,
|
|
903
|
+
functionName: 'execute',
|
|
904
|
+
args: [
|
|
905
|
+
getArgByName(originalTransaction.children[0].args, 'operationType') as number, // ✓ Preserved
|
|
906
|
+
getArgByName(originalTransaction.children[0].args, 'target') as string, // ✓ Preserved
|
|
907
|
+
getArgByName(originalTransaction.children[0].args, 'value') as bigint, // ✓ Preserved
|
|
908
|
+
modifiedData // ✓ Updated
|
|
909
|
+
]
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
// This preserves:
|
|
913
|
+
// - Original operationType
|
|
914
|
+
// - Original value
|
|
915
|
+
// - Signature validity (if recomposed correctly)
|
|
916
|
+
// - All other wrapper arguments
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
### Key Principle
|
|
920
|
+
|
|
921
|
+
**Always start with the original transaction and recompose it, never create new wrappers from scratch.**
|
|
922
|
+
|
|
923
|
+
This ensures:
|
|
924
|
+
1. ✅ All wrapper arguments are preserved
|
|
925
|
+
2. ✅ Signatures remain valid (if signing over final data)
|
|
926
|
+
3. ✅ Context is maintained (nonces, timestamps, etc.)
|
|
927
|
+
4. ✅ Only the intended modification is applied
|