@nockchain/rose 0.1.4-nightly.5

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.
Files changed (205) hide show
  1. package/.github/workflows/artifacts.yml +33 -0
  2. package/.github/workflows/ci.yml +68 -0
  3. package/.github/workflows/publish-sdk.yml +35 -0
  4. package/.nvmrc +1 -0
  5. package/.prettierignore +5 -0
  6. package/.prettierrc +8 -0
  7. package/LICENSE +22 -0
  8. package/README.md +117 -0
  9. package/extension/background/index.ts +1500 -0
  10. package/extension/content/index.ts +59 -0
  11. package/extension/icons/rose.svg +27 -0
  12. package/extension/icons/rose128.png +0 -0
  13. package/extension/icons/rose16.png +0 -0
  14. package/extension/icons/rose256.png +0 -0
  15. package/extension/icons/rose32.png +0 -0
  16. package/extension/icons/rose48.png +0 -0
  17. package/extension/icons/rose512.png +0 -0
  18. package/extension/inpage/index.ts +86 -0
  19. package/extension/manifest.json +48 -0
  20. package/extension/popup/Popup.tsx +94 -0
  21. package/extension/popup/Router.tsx +121 -0
  22. package/extension/popup/assets/arrow-down-icon.svg +3 -0
  23. package/extension/popup/assets/arrow-left-icon.svg +3 -0
  24. package/extension/popup/assets/arrow-right-icon.svg +3 -0
  25. package/extension/popup/assets/arrow-up-icon.svg +3 -0
  26. package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
  27. package/extension/popup/assets/checkmark-icon.svg +3 -0
  28. package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
  29. package/extension/popup/assets/checkmark-success-icon.svg +3 -0
  30. package/extension/popup/assets/clock-icon.svg +3 -0
  31. package/extension/popup/assets/close-x-icon.svg +3 -0
  32. package/extension/popup/assets/copy-icon.svg +6 -0
  33. package/extension/popup/assets/explorer-icon.svg +3 -0
  34. package/extension/popup/assets/eye-off-icon.svg +3 -0
  35. package/extension/popup/assets/eye-open-icon.svg +4 -0
  36. package/extension/popup/assets/feedback-icon.svg +3 -0
  37. package/extension/popup/assets/green-status-dot.svg +3 -0
  38. package/extension/popup/assets/info-icon.svg +3 -0
  39. package/extension/popup/assets/iris-logo-40.svg +27 -0
  40. package/extension/popup/assets/iris-logo-96.svg +27 -0
  41. package/extension/popup/assets/iris-logo-blue.svg +27 -0
  42. package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
  43. package/extension/popup/assets/iris-logo-orange.svg +27 -0
  44. package/extension/popup/assets/iris-logo.svg +27 -0
  45. package/extension/popup/assets/key-icon.svg +3 -0
  46. package/extension/popup/assets/lock-icon-yellow.svg +3 -0
  47. package/extension/popup/assets/lock-icon.svg +3 -0
  48. package/extension/popup/assets/pencil-edit-icon.svg +3 -0
  49. package/extension/popup/assets/permissions-icon.svg +3 -0
  50. package/extension/popup/assets/receipt-icon.svg +5 -0
  51. package/extension/popup/assets/refresh-icon.svg +3 -0
  52. package/extension/popup/assets/settings-gear-icon.svg +8 -0
  53. package/extension/popup/assets/settings-icon.svg +3 -0
  54. package/extension/popup/assets/theme-icon.svg +3 -0
  55. package/extension/popup/assets/trash-bin-icon.svg +3 -0
  56. package/extension/popup/assets/trend-down-arrow.svg +5 -0
  57. package/extension/popup/assets/trend-up-arrow.svg +5 -0
  58. package/extension/popup/assets/user-account-icon.svg +3 -0
  59. package/extension/popup/assets/vector-bottom-left.svg +9 -0
  60. package/extension/popup/assets/vector-left.svg +9 -0
  61. package/extension/popup/assets/vector-right.svg +9 -0
  62. package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
  63. package/extension/popup/assets/vector-top-right.svg +9 -0
  64. package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
  65. package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
  66. package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
  67. package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
  68. package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
  69. package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
  70. package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
  71. package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
  72. package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
  73. package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
  74. package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
  75. package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
  76. package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
  77. package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
  78. package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
  79. package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
  80. package/extension/popup/components/AccountIcon.tsx +78 -0
  81. package/extension/popup/components/AccountSelector.tsx +246 -0
  82. package/extension/popup/components/Alert.tsx +48 -0
  83. package/extension/popup/components/ConfirmModal.tsx +81 -0
  84. package/extension/popup/components/PasswordInput.tsx +49 -0
  85. package/extension/popup/components/ScreenContainer.tsx +17 -0
  86. package/extension/popup/components/SiteIcon.tsx +60 -0
  87. package/extension/popup/components/ThemeToggle.tsx +44 -0
  88. package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
  89. package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
  90. package/extension/popup/components/icons/CheckIcon.tsx +20 -0
  91. package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
  92. package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
  93. package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
  94. package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
  95. package/extension/popup/components/icons/CloseIcon.tsx +26 -0
  96. package/extension/popup/components/icons/CopyIcon.tsx +20 -0
  97. package/extension/popup/components/icons/EditIcon.tsx +20 -0
  98. package/extension/popup/components/icons/EyeIcon.tsx +13 -0
  99. package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
  100. package/extension/popup/components/icons/InfoIcon.tsx +20 -0
  101. package/extension/popup/components/icons/LockIcon.tsx +20 -0
  102. package/extension/popup/components/icons/PlusIcon.tsx +15 -0
  103. package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
  104. package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
  105. package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
  106. package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
  107. package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
  108. package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
  109. package/extension/popup/components/icons/UploadIcon.tsx +20 -0
  110. package/extension/popup/components/icons/WalletIcon.tsx +20 -0
  111. package/extension/popup/contexts/ThemeContext.tsx +105 -0
  112. package/extension/popup/hooks/useApprovalDetection.ts +128 -0
  113. package/extension/popup/hooks/useAutoFocus.ts +36 -0
  114. package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
  115. package/extension/popup/hooks/useClickOutside.ts +33 -0
  116. package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
  117. package/extension/popup/hooks/useFavicon.ts +64 -0
  118. package/extension/popup/hooks/useNumericInput.ts +93 -0
  119. package/extension/popup/index.html +13 -0
  120. package/extension/popup/index.tsx +24 -0
  121. package/extension/popup/screens/AboutScreen.tsx +118 -0
  122. package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
  123. package/extension/popup/screens/HomeScreen.tsx +902 -0
  124. package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
  125. package/extension/popup/screens/LockTimeScreen.tsx +155 -0
  126. package/extension/popup/screens/ReceiveScreen.tsx +149 -0
  127. package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
  128. package/extension/popup/screens/SendReviewScreen.tsx +308 -0
  129. package/extension/popup/screens/SendScreen.tsx +825 -0
  130. package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
  131. package/extension/popup/screens/SettingsScreen.tsx +116 -0
  132. package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
  133. package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
  134. package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
  135. package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
  136. package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
  137. package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
  138. package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
  139. package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
  140. package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
  141. package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
  142. package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
  143. package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
  144. package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
  145. package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
  146. package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
  147. package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
  148. package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
  149. package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
  150. package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
  151. package/extension/popup/screens/system/LockedScreen.tsx +288 -0
  152. package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
  153. package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
  154. package/extension/popup/store.ts +482 -0
  155. package/extension/popup/styles.css +246 -0
  156. package/extension/popup/utils/format.ts +58 -0
  157. package/extension/popup/utils/formatWalletError.ts +36 -0
  158. package/extension/popup/utils/memo.ts +299 -0
  159. package/extension/popup/utils/messaging.ts +16 -0
  160. package/extension/shared/address-encoding.ts +69 -0
  161. package/extension/shared/balance-query.ts +123 -0
  162. package/extension/shared/constants.ts +386 -0
  163. package/extension/shared/currency.ts +128 -0
  164. package/extension/shared/first-name-derivation.ts +128 -0
  165. package/extension/shared/keyfile.ts +58 -0
  166. package/extension/shared/onboarding.ts +78 -0
  167. package/extension/shared/price-api.ts +79 -0
  168. package/extension/shared/rpc-client-browser.ts +315 -0
  169. package/extension/shared/transaction-builder.ts +443 -0
  170. package/extension/shared/types.ts +450 -0
  171. package/extension/shared/utxo-diff.ts +212 -0
  172. package/extension/shared/utxo-store.ts +548 -0
  173. package/extension/shared/utxo-sync.ts +343 -0
  174. package/extension/shared/validators.ts +26 -0
  175. package/extension/shared/vault.ts +1580 -0
  176. package/extension/shared/wallet-crypto.ts +77 -0
  177. package/extension/shared/wasm-utils.ts +76 -0
  178. package/extension/shared/webcrypto.ts +67 -0
  179. package/extension/types/wasm.d.ts +13 -0
  180. package/package.json +39 -0
  181. package/postcss.config.js +6 -0
  182. package/rose-extension-dist.zip +0 -0
  183. package/sdk/README.md +88 -0
  184. package/sdk/examples/app.ts +166 -0
  185. package/sdk/examples/index.html +51 -0
  186. package/sdk/examples/tsconfig.json +15 -0
  187. package/sdk/examples/tx-builder.html +532 -0
  188. package/sdk/examples/tx-builder.ts +1766 -0
  189. package/sdk/package-lock.json +424 -0
  190. package/sdk/package.json +68 -0
  191. package/sdk/src/constants.ts +28 -0
  192. package/sdk/src/errors.ts +74 -0
  193. package/sdk/src/hooks/index.ts +1 -0
  194. package/sdk/src/hooks/use-rose.ts +94 -0
  195. package/sdk/src/index.ts +12 -0
  196. package/sdk/src/provider.ts +396 -0
  197. package/sdk/src/transaction.ts +163 -0
  198. package/sdk/src/types/rose-wasm.d.ts +14 -0
  199. package/sdk/src/types.ts +97 -0
  200. package/sdk/src/wasm.ts +13 -0
  201. package/sdk/tsconfig.json +20 -0
  202. package/sdk/vite.config.examples.ts +32 -0
  203. package/tailwind.config.ts +38 -0
  204. package/tsconfig.json +20 -0
  205. package/vite.config.ts +60 -0
@@ -0,0 +1,1766 @@
1
+ import { NockchainProvider, wasm } from '../src/index';
2
+
3
+ // ===== Types =====
4
+
5
+ interface Lock {
6
+ id: string;
7
+ name: string;
8
+ spendConditionProtobuf: Uint8Array; // Store protobuf instead of WASM object
9
+ expanded: boolean;
10
+ }
11
+
12
+ interface NoteData {
13
+ note: wasm.Note;
14
+ assets: bigint;
15
+ firstName: string;
16
+ lastName: string;
17
+ }
18
+
19
+ interface SelectedInput {
20
+ lockId: string;
21
+ note: NoteData;
22
+ id: string; // unique identifier
23
+ }
24
+
25
+ interface Seed {
26
+ lockId: string;
27
+ amount: bigint; // in nicks
28
+ }
29
+
30
+ interface Spend {
31
+ inputId: string;
32
+ input: SelectedInput;
33
+ fee: bigint; // in nicks
34
+ seeds: Seed[];
35
+ }
36
+
37
+ // ===== State =====
38
+
39
+ const state = {
40
+ // Connection
41
+ connected: false,
42
+ walletPkh: null as string | null,
43
+ grpcEndpoint: null as string | null,
44
+ provider: null as NockchainProvider | null,
45
+ grpcClient: null as wasm.GrpcClient | null,
46
+
47
+ // Locks & Notes
48
+ locks: [] as Lock[],
49
+ notes: new Map<string, NoteData[]>(), // lockId -> notes
50
+
51
+ // Transaction Building
52
+ selectedInputs: [] as SelectedInput[],
53
+ spends: [] as Spend[],
54
+ builder: null as wasm.TxBuilder | null,
55
+
56
+ // Unlocking
57
+ preimages: new Map<string, Uint8Array>(), // hash -> jam bytes
58
+ missingUnlocks: [] as any[], // Missing type definition in WASM module
59
+
60
+ // Transaction
61
+ nockchainTx: null as wasm.NockchainTx | null,
62
+ signedTx: null as wasm.NockchainTx | null,
63
+ signedTxId: null as string | null,
64
+ };
65
+
66
+ // ===== DOM Elements =====
67
+
68
+ const connectBtn = document.getElementById('connectBtn') as HTMLButtonElement;
69
+ const addLockBtn = document.getElementById('addLockBtn') as HTMLButtonElement;
70
+ const refreshAllBtn = document.getElementById('refreshAllBtn') as HTMLButtonElement;
71
+ const lockList = document.getElementById('lockList')!;
72
+ const spendsList = document.getElementById('spendsList')!;
73
+ const preimagesList = document.getElementById('preimagesList')!;
74
+ const unlocksList = document.getElementById('unlocksList')!;
75
+ const txValidation = document.getElementById('txValidation')!;
76
+ const txInfo = document.getElementById('txInfo')!;
77
+ const outputsList = document.getElementById('outputsList')!;
78
+ const downloadTxBtn = document.getElementById('downloadTxBtn') as HTMLButtonElement;
79
+ const signTxBtn = document.getElementById('signTxBtn') as HTMLButtonElement;
80
+ const signedTxSection = document.getElementById('signedTxSection') as HTMLElement;
81
+ const signedTxIdEl = document.getElementById('signedTxId') as HTMLElement;
82
+
83
+ const addLockModal = document.getElementById('addLockModal')!;
84
+ const closeLockModal = document.getElementById('closeLockModal')!;
85
+ const lockNameInput = document.getElementById('lockNameInput') as HTMLInputElement;
86
+ const primitivesContainer = document.getElementById('primitivesContainer')!;
87
+ const addPrimitiveBtn = document.getElementById('addPrimitiveBtn') as HTMLButtonElement;
88
+ const confirmAddLockBtn = document.getElementById('confirmAddLockBtn') as HTMLButtonElement;
89
+ const cancelAddLockBtn = document.getElementById('cancelAddLockBtn') as HTMLButtonElement;
90
+ const importLocksBtn = document.getElementById('importLocksBtn') as HTMLButtonElement;
91
+ const exportLocksBtn = document.getElementById('exportLocksBtn') as HTMLButtonElement;
92
+
93
+ const addPreimageBtn = document.getElementById('addPreimageBtn') as HTMLButtonElement;
94
+ const addPreimageModal = document.getElementById('addPreimageModal')!;
95
+ const closePreimageModal = document.getElementById('closePreimageModal')!;
96
+ const preimageFileInput = document.getElementById('preimageFileInput') as HTMLInputElement;
97
+ const addPreimageConfirmBtn = document.getElementById('addPreimageConfirmBtn') as HTMLButtonElement;
98
+ const cancelPreimageBtn = document.getElementById('cancelPreimageBtn') as HTMLButtonElement;
99
+
100
+ // ===== Utilities =====
101
+
102
+ function truncateAddress(addr: string | undefined): string {
103
+ if (!addr) return '';
104
+ return `${addr.slice(0, 4)}...${addr.slice(-4)}`;
105
+ }
106
+
107
+ function nicksToNock(nicks: bigint): number {
108
+ return Number(nicks) / 65536;
109
+ }
110
+
111
+ function nockToNicks(nock: number): bigint {
112
+ return BigInt(Math.floor(nock * 65536));
113
+ }
114
+
115
+ function formatNock(nock: number): string {
116
+ return nock.toFixed(4);
117
+ }
118
+
119
+ declare global {
120
+ interface Window {
121
+ copyToClipboard: (text: string, element: HTMLElement) => void;
122
+ }
123
+ }
124
+
125
+ // Add to window object
126
+ window.copyToClipboard = (text: string, element: HTMLElement) => {
127
+ navigator.clipboard
128
+ .writeText(text)
129
+ .then(() => {
130
+ const tooltip = document.createElement('span');
131
+ tooltip.textContent = 'Copied!';
132
+ tooltip.style.cssText = `
133
+ position: absolute;
134
+ top: -25px;
135
+ right: 0;
136
+ background: #10b981;
137
+ color: white;
138
+ padding: 2px 6px;
139
+ border-radius: 4px;
140
+ font-size: 10px;
141
+ pointer-events: none;
142
+ animation: fadeOut 1s forwards;
143
+ animation-delay: 1s;
144
+ z-index: 100;
145
+ `;
146
+ element.appendChild(tooltip);
147
+ setTimeout(() => {
148
+ if (tooltip.parentNode === element) {
149
+ element.removeChild(tooltip);
150
+ }
151
+ }, 2000);
152
+ })
153
+ .catch(err => console.error('Failed to copy:', err));
154
+ };
155
+
156
+ function renderNoteName(firstName: string, lastName: string, bright: boolean = false): string {
157
+ const fullName = `[ ${firstName} ${lastName} ]`;
158
+ const truncatedName = `[ ${firstName.slice(0, 4)}...${lastName.slice(-4)} ]`;
159
+ // Escape quotes for HTML attribute
160
+ const escapedFullName = fullName.replace(/"/g, '&quot;').replace(/'/g, "\\'");
161
+
162
+ const color = bright ? '#e0e0e0' : '#9ca3af';
163
+ const weight = bright ? '600' : 'normal';
164
+
165
+ return `
166
+ <span
167
+ class="font-mono cursor-pointer hover:opacity-80 transition-opacity relative group"
168
+ style="color: ${color}; font-weight: ${weight}; font-family: monospace; position: relative;"
169
+ onclick="window.copyToClipboard('${escapedFullName}', this)"
170
+ title="Click to copy: ${escapedFullName}"
171
+ >
172
+ ${truncatedName}
173
+ </span>
174
+ `;
175
+ }
176
+
177
+ function renderCopyableId(id: string, label: string = 'ID'): string {
178
+ const truncated = `${id.substring(0, 16)}...`;
179
+ return `
180
+ <span
181
+ class="font-mono cursor-pointer hover:opacity-80 transition-opacity relative group"
182
+ style="font-family: monospace; font-size: 0.75rem; position: relative;"
183
+ onclick="window.copyToClipboard('${id}', this)"
184
+ title="Click to copy full ${label}"
185
+ >
186
+ ${truncated}
187
+ </span>
188
+ `;
189
+ }
190
+
191
+ function generateId(): string {
192
+ return Math.random().toString(36).substring(2, 11);
193
+ }
194
+
195
+ // ===== Initialization =====
196
+
197
+ async function init() {
198
+ try {
199
+ await wasm.default();
200
+ console.log('WASM initialized');
201
+
202
+ state.provider = new NockchainProvider();
203
+ console.log('NockchainProvider initialized');
204
+
205
+ // Create default locks
206
+ createDefaultLocks();
207
+ renderLocks();
208
+ } catch (e) {
209
+ console.error('Failed to init:', e);
210
+ alert('Failed to initialize WASM');
211
+ }
212
+ }
213
+
214
+ function createDefaultLocks() {
215
+ // We'll create default locks after wallet connects
216
+ // For now, just initialize empty
217
+ }
218
+
219
+ // ===== Connection =====
220
+
221
+ connectBtn.onclick = async () => {
222
+ if (!state.provider) {
223
+ alert('Provider not initialized');
224
+ return;
225
+ }
226
+
227
+ try {
228
+ const info = await state.provider.connect();
229
+ state.grpcEndpoint = info.grpcEndpoint;
230
+ state.walletPkh = info.pkh;
231
+ state.connected = true;
232
+
233
+ connectBtn.textContent = truncateAddress(state.walletPkh);
234
+ console.log('Connected:', state.walletPkh);
235
+
236
+ // Create gRPC client
237
+ state.grpcClient = new wasm.GrpcClient(state.grpcEndpoint);
238
+
239
+ // Create default locks now that we have wallet PKH
240
+ createDefaultLocksWithPkh();
241
+ renderLocks();
242
+ } catch (e) {
243
+ console.error('Connect failed:', e);
244
+ alert('Failed to connect: ' + (e instanceof Error ? e.message : String(e)));
245
+ }
246
+ };
247
+
248
+ function createDefaultLocksWithPkh() {
249
+ if (!state.walletPkh) return;
250
+
251
+ // Check if default locks already exist to prevent duplicates
252
+ const pkhDefaultExists = state.locks.some(l => l.id === 'pkh-default');
253
+ const coinbaseDefaultExists = state.locks.some(l => l.id === 'coinbase-default');
254
+
255
+ if (!pkhDefaultExists) {
256
+ // 1. PKH lock
257
+ const pkhLock = wasm.Pkh.single(state.walletPkh);
258
+ const pkhSpendCondition = wasm.SpendCondition.newPkh(pkhLock);
259
+ state.locks.push({
260
+ id: 'pkh-default',
261
+ name: 'Wallet PKH',
262
+ spendConditionProtobuf: pkhSpendCondition.toProtobuf(),
263
+ expanded: false,
264
+ });
265
+ pkhSpendCondition.free();
266
+ }
267
+
268
+ if (!coinbaseDefaultExists) {
269
+ // 2. Coinbase lock (PKH + timelock)
270
+ const coinbaseLock = wasm.Pkh.single(state.walletPkh);
271
+ const timelockCoinbase = wasm.LockTim.coinbase();
272
+ const coinbaseSpendCondition = new wasm.SpendCondition([
273
+ wasm.LockPrimitive.newPkh(coinbaseLock),
274
+ wasm.LockPrimitive.newTim(timelockCoinbase),
275
+ ]);
276
+ state.locks.push({
277
+ id: 'coinbase-default',
278
+ name: 'Wallet Coinbase',
279
+ spendConditionProtobuf: coinbaseSpendCondition.toProtobuf(),
280
+ expanded: false,
281
+ });
282
+ coinbaseSpendCondition.free();
283
+ }
284
+ }
285
+
286
+ // ===== Lock Management =====
287
+
288
+ function renderLocks() {
289
+ lockList.innerHTML = '';
290
+
291
+ if (state.locks.length === 0) {
292
+ lockList.innerHTML =
293
+ '<div class="empty-state">No locks defined. Add a lock to get started.</div>';
294
+ return;
295
+ }
296
+
297
+ state.locks.forEach(lock => {
298
+ const lockEl = document.createElement('div');
299
+ const lockHtml = `
300
+ <div class="lock-item">
301
+ <div class="lock-header" data-lock-id="${lock.id}">
302
+ <div class="lock-title">${lock.name}</div>
303
+ <div class="lock-actions">
304
+ <button class="btn btn-sm" data-action="refresh" data-lock-id="${lock.id}">↻</button>
305
+ <button class="btn btn-danger btn-sm" data-action="remove" data-lock-id="${lock.id}">×</button>
306
+ </div>
307
+ </div>
308
+ <div class="note-list ${lock.expanded ? '' : 'hidden'}" data-lock-id="${lock.id}">
309
+ <div style="padding: 1rem; text-align: center; color: #6b7280; font-size: 0.75rem">Loading...</div>
310
+ </div>
311
+ </div>
312
+ `;
313
+ lockEl.innerHTML = lockHtml;
314
+ lockList.appendChild(lockEl);
315
+ });
316
+
317
+ // Re-render notes for expanded locks
318
+ state.locks
319
+ .filter(l => l.expanded)
320
+ .forEach(lock => {
321
+ renderNotesForLock(lock.id);
322
+ });
323
+ }
324
+
325
+ async function refreshNotesForLock(lockId: string) {
326
+ const lock = state.locks.find(l => l.id === lockId);
327
+ if (!lock || !state.grpcClient) return;
328
+
329
+ try {
330
+ // Deserialize spend condition from protobuf to get firstName
331
+ const spendCondition = wasm.SpendCondition.fromProtobuf(lock.spendConditionProtobuf);
332
+ const firstName = spendCondition.firstName();
333
+ console.log(
334
+ `Fetching notes for lock ${lockId}, firstName: ${firstName.value.substring(0, 20)}...`
335
+ );
336
+
337
+ const balance = await state.grpcClient.getBalanceByFirstName(firstName.value);
338
+ spendCondition.free(); // Clean up
339
+
340
+ if (!balance || !balance.notes || balance.notes.length === 0) {
341
+ state.notes.set(lockId, []);
342
+ renderNotesForLock(lockId);
343
+ return;
344
+ }
345
+
346
+ const notes: NoteData[] = (
347
+ balance.notes as Array<{ note: any; firstName?: string; lastName?: string }>
348
+ ).map(n => {
349
+ const note = wasm.Note.fromProtobuf(n.note);
350
+
351
+ // Try to extract name from protobuf if top-level fields are empty
352
+ let firstName = n.firstName || '';
353
+ let lastName = n.lastName || '';
354
+
355
+ if (!firstName && n.note?.note_version?.V1?.name) {
356
+ firstName = n.note.note_version.V1.name.first || '';
357
+ lastName = n.note.note_version.V1.name.last || '';
358
+ }
359
+
360
+ return {
361
+ note,
362
+ assets: note.assets,
363
+ firstName,
364
+ lastName,
365
+ };
366
+ });
367
+
368
+ state.notes.set(lockId, notes);
369
+ renderNotesForLock(lockId);
370
+ } catch (e) {
371
+ console.error('Failed to fetch notes:', e);
372
+ }
373
+ }
374
+
375
+ function renderNotesForLock(lockId: string) {
376
+ const notesContainer = document.querySelector(
377
+ `.note-list[data-lock-id="${lockId}"]`
378
+ ) as HTMLElement;
379
+ if (!notesContainer) return;
380
+
381
+ const notes = state.notes.get(lockId) || [];
382
+ if (notes.length === 0) {
383
+ notesContainer.innerHTML =
384
+ '<div class="empty-state" style="padding: 1rem">No notes found</div>';
385
+ return;
386
+ }
387
+
388
+ notesContainer.innerHTML = notes
389
+ .map(
390
+ (note, index) => `
391
+ <div class="note-item">
392
+ <div class="note-info">
393
+ <div class="note-amount">${formatNock(nicksToNock(note.assets))} NOCK</div>
394
+ <div style="font-size: 0.7rem">${renderNoteName(note.firstName, note.lastName)}</div>
395
+ </div>
396
+ <button class="btn btn-sm" data-action="select-note" data-lock-id="${lockId}" data-note-index="${index}">+</button>
397
+ </div>
398
+ `
399
+ )
400
+ .join('');
401
+ }
402
+
403
+ async function refreshAllNotes() {
404
+ for (const lock of state.locks) {
405
+ await refreshNotesForLock(lock.id);
406
+ }
407
+ }
408
+
409
+ function removeLock(lockId: string) {
410
+ state.locks = state.locks.filter(l => l.id !== lockId);
411
+ state.notes.delete(lockId);
412
+ // Also remove any selected inputs from this lock
413
+ state.selectedInputs = state.selectedInputs.filter(input => input.lockId !== lockId);
414
+ state.spends = state.spends.filter(spend => spend.input.lockId !== lockId);
415
+ renderLocks();
416
+ renderSpends(); // Re-render spends as some might have been removed
417
+ updateBuilder(); // Rebuild transaction if inputs changed
418
+ }
419
+
420
+ // ===== Spend Management =====
421
+
422
+ function addInputToSpend(lockId: string, noteIndex: number) {
423
+ const notes = state.notes.get(lockId);
424
+ if (!notes || !notes[noteIndex]) return;
425
+
426
+ const input: SelectedInput = {
427
+ lockId,
428
+ note: notes[noteIndex],
429
+ id: generateId(),
430
+ };
431
+
432
+ state.selectedInputs.push(input);
433
+
434
+ const spend: Spend = {
435
+ inputId: input.id,
436
+ input,
437
+ fee: BigInt(0),
438
+ seeds: [],
439
+ };
440
+
441
+ state.spends.push(spend);
442
+ renderSpends();
443
+ updateBuilder();
444
+ }
445
+
446
+ function renderSpends() {
447
+ if (state.spends.length === 0) {
448
+ spendsList.innerHTML =
449
+ '<div class="empty-state">Select input notes from the left panel to start building a transaction</div>';
450
+ return;
451
+ }
452
+
453
+ spendsList.innerHTML = state.spends
454
+ .map(
455
+ spend => `
456
+ <div class="spend-item ${isSpendBalanced(spend) ? 'spend-balanced' : 'spend-unbalanced'}">
457
+ <div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem">
458
+ <div style="font-weight: 600">${renderNoteName(spend.input.note.firstName, spend.input.note.lastName, true)}</div>
459
+ <button class="btn btn-danger btn-sm" data-action="remove-spend" data-input-id="${spend.input.id}">Remove Spend</button>
460
+ </div>
461
+ <div style="font-weight: 600; margin-bottom: 0.25rem">Input: ${formatNock(nicksToNock(spend.input.note.assets))} NOCK</div>
462
+ <div style="font-size: 0.75rem; color: #9ca3af">${spend.input.lockId}</div>
463
+
464
+ <div class="form-group">
465
+ <label class="form-label">Fee (NOCK)</label>
466
+ <input type="number" class="input" step="0.0001" value="${nicksToNock(spend.fee)}"
467
+ data-action="update-fee" data-input-id="${spend.inputId}" />
468
+ </div>
469
+
470
+ <div style="margin-top: 0.75rem">
471
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem">
472
+ <span class="form-label" style="margin: 0">Seeds</span>
473
+ <button class="btn btn-sm" data-action="add-seed" data-input-id="${spend.inputId}">+</button>
474
+ </div>
475
+ ${renderSeeds(spend)}
476
+ </div>
477
+
478
+ <div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #333">
479
+ <div style="font-size: 0.75rem; color: #9ca3af">Balance: ${getSpendBalanceText(spend)}</div>
480
+ </div>
481
+ </div>
482
+ `
483
+ )
484
+ .join('');
485
+ }
486
+
487
+ function renderSeeds(spend: Spend): string {
488
+ if (spend.seeds.length === 0) {
489
+ return '<div style="font-size: 0.75rem; color: #6b7280">No seeds</div>';
490
+ }
491
+
492
+ return spend.seeds
493
+ .map(
494
+ (seed, index) => `
495
+ <div class="seed-item">
496
+ <select class="input" style="flex: 1" data-action="update-seed-lock" data-input-id="${spend.inputId}" data-seed-index="${index}">
497
+ ${state.locks.map(l => `<option value="${l.id}" ${l.id === seed.lockId ? 'selected' : ''}>${l.name}</option>`).join('')}
498
+ </select>
499
+ <input type="number" class="input" style="width: 120px" step="0.0001" value="${nicksToNock(seed.amount)}"
500
+ data-action="update-seed-amount" data-input-id="${spend.inputId}" data-seed-index="${index}" />
501
+ <button class="btn btn-sm" data-action="balance-seed" data-input-id="${spend.inputId}" data-seed-index="${index}" title="Balance remaining">⚖</button>
502
+ <button class="btn btn-danger btn-sm" data-action="remove-seed" data-input-id="${spend.inputId}" data-seed-index="${index}">×</button>
503
+ </div>
504
+ `
505
+ )
506
+ .join('');
507
+ }
508
+
509
+ function isSpendBalanced(spend: Spend): boolean {
510
+ const total = spend.fee + spend.seeds.reduce((sum, seed) => sum + seed.amount, BigInt(0));
511
+ return total === spend.input.note.assets;
512
+ }
513
+
514
+ function getSpendBalanceText(spend: Spend): string {
515
+ const total = spend.fee + spend.seeds.reduce((sum, seed) => sum + seed.amount, BigInt(0));
516
+ const diff = spend.input.note.assets - total;
517
+ if (diff === BigInt(0)) {
518
+ return `✓ Balanced (${formatNock(nicksToNock(total))} NOCK)`;
519
+ }
520
+ const sign = diff > 0 ? '+' : '';
521
+ return `${sign}${formatNock(nicksToNock(diff))} NOCK`;
522
+ }
523
+
524
+ function removeSpend(inputId: string) {
525
+ const index = state.spends.findIndex(s => s.inputId === inputId);
526
+ if (index >= 0) {
527
+ state.spends.splice(index, 1);
528
+
529
+ // Also remove from selectedInputs
530
+ const selectedIndex = state.selectedInputs.findIndex(i => i.id === inputId);
531
+ if (selectedIndex >= 0) {
532
+ state.selectedInputs.splice(selectedIndex, 1);
533
+ }
534
+
535
+ renderSpends();
536
+ updateBuilder();
537
+ }
538
+ }
539
+
540
+ function balanceSeed(inputId: string, seedIndex: number) {
541
+ const spend = state.spends.find(s => s.inputId === inputId);
542
+ if (!spend) return;
543
+
544
+ const seed = spend.seeds[seedIndex];
545
+ if (!seed) return;
546
+
547
+ // Calculate remaining assets: input assets - fee - other seeds
548
+ const inputAssets = spend.input.note.assets;
549
+ const fee = spend.fee;
550
+
551
+ let otherSeedsTotal = BigInt(0);
552
+ for (let i = 0; i < spend.seeds.length; i++) {
553
+ if (i !== seedIndex) {
554
+ otherSeedsTotal += spend.seeds[i].amount;
555
+ }
556
+ }
557
+
558
+ const remaining = inputAssets - fee - otherSeedsTotal;
559
+
560
+ if (remaining < 0n) {
561
+ alert('Insufficient funds to balance this seed. Please reduce fee or other seeds.');
562
+ return;
563
+ }
564
+
565
+ seed.amount = remaining;
566
+ renderSpends();
567
+ updateBuilder();
568
+ }
569
+
570
+ function updateSpendFee(inputId: string, feeStr: string, shouldRender: boolean = true) {
571
+ const spend = state.spends.find(s => s.inputId === inputId);
572
+ if (spend) {
573
+ try {
574
+ // Convert NOCK to nicks
575
+ const nock = parseFloat(feeStr);
576
+ if (!isNaN(nock) && nock >= 0) {
577
+ spend.fee = BigInt(Math.floor(nock * 65536));
578
+ } else {
579
+ spend.fee = BigInt(0);
580
+ }
581
+ if (shouldRender) renderSpends();
582
+ updateBuilder();
583
+ } catch (e) {
584
+ console.error('Invalid fee', e);
585
+ }
586
+ }
587
+ }
588
+
589
+ function addSeed(inputId: string) {
590
+ const spend = state.spends.find(s => s.inputId === inputId);
591
+ if (spend && state.locks.length > 0) {
592
+ spend.seeds.push({
593
+ lockId: state.locks[0].id,
594
+ amount: BigInt(0),
595
+ });
596
+ renderSpends();
597
+ updateBuilder();
598
+ }
599
+ }
600
+
601
+ function updateSeedLock(inputId: string, seedIndex: number, lockId: string) {
602
+ const spend = state.spends.find(s => s.inputId === inputId);
603
+ if (spend && spend.seeds[seedIndex]) {
604
+ spend.seeds[seedIndex].lockId = lockId;
605
+ renderSpends();
606
+ updateBuilder();
607
+ }
608
+ }
609
+
610
+ function updateSeedAmount(
611
+ inputId: string,
612
+ seedIndex: number,
613
+ amountStr: string,
614
+ shouldRender = true
615
+ ) {
616
+ const spend = state.spends.find(s => s.inputId === inputId);
617
+ if (spend && spend.seeds[seedIndex]) {
618
+ try {
619
+ // Convert NOCK to nicks
620
+ const nock = parseFloat(amountStr);
621
+ if (!isNaN(nock) && nock >= 0) {
622
+ spend.seeds[seedIndex].amount = BigInt(Math.floor(nock * 65536));
623
+ } else {
624
+ spend.seeds[seedIndex].amount = BigInt(0);
625
+ }
626
+ if (shouldRender) renderSpends();
627
+ updateBuilder();
628
+ } catch (e) {
629
+ console.error('Invalid amount', e);
630
+ }
631
+ }
632
+ }
633
+
634
+ function removeSeed(inputId: string, seedIndex: number) {
635
+ const spend = state.spends.find(s => s.inputId === inputId);
636
+ if (spend) {
637
+ spend.seeds.splice(seedIndex, 1);
638
+ renderSpends();
639
+ updateBuilder();
640
+ }
641
+ }
642
+
643
+ // ===== Transaction Builder =====
644
+
645
+ function updateBuilder() {
646
+ const allBalanced = state.spends.length > 0 && state.spends.every(isSpendBalanced);
647
+
648
+ if (!allBalanced) {
649
+ state.builder = null;
650
+ state.nockchainTx = null;
651
+ state.missingUnlocks = [];
652
+ state.signedTx = null; // Clear signed TX state
653
+ state.signedTxId = null;
654
+ signedTxSection.classList.add('hidden');
655
+ renderUnlocks();
656
+ renderTransaction();
657
+ return;
658
+ }
659
+
660
+ try {
661
+ console.log('Building transaction...');
662
+
663
+ // Create TxBuilder with default fee-per-word
664
+ const feePerWord = BigInt(32768); // 0.5 NOCK per word
665
+ const builder = new wasm.TxBuilder(feePerWord);
666
+
667
+ // Add each spend to the builder using SpendBuilder
668
+ for (const spend of state.spends) {
669
+ const lock = state.locks.find(l => l.id === spend.input.lockId);
670
+ if (!lock) continue;
671
+
672
+ // Determine refund lock protobuf (use first seed lock if available, otherwise same as input)
673
+ const refundLockProtobuf =
674
+ spend.seeds.length > 0
675
+ ? state.locks.find(l => l.id === spend.seeds[0].lockId)?.spendConditionProtobuf
676
+ : lock.spendConditionProtobuf;
677
+
678
+ // Deserialize WASM objects from protobuf (fresh instances every time)
679
+ const noteClone = wasm.Note.fromProtobuf(spend.input.note.note.toProtobuf());
680
+ const spendConditionClone = wasm.SpendCondition.fromProtobuf(lock.spendConditionProtobuf);
681
+ const refundLockClone = refundLockProtobuf
682
+ ? wasm.SpendCondition.fromProtobuf(refundLockProtobuf)
683
+ : null;
684
+
685
+ // Create SpendBuilder with note, spend condition, and refund lock
686
+ const spendBuilder = new wasm.SpendBuilder(noteClone, spendConditionClone, refundLockClone);
687
+
688
+ // Add seeds (outputs)
689
+ for (const seed of spend.seeds) {
690
+ const seedLock = state.locks.find(l => l.id === seed.lockId);
691
+ if (seedLock) {
692
+ // Deserialize spend condition from protobuf
693
+ const seedSpendCondition = wasm.SpendCondition.fromProtobuf(
694
+ seedLock.spendConditionProtobuf
695
+ );
696
+ // Create a Seed for this output using the constructor
697
+ const seedObj = new wasm.Seed(
698
+ null, // output_source
699
+ wasm.LockRoot.fromSpendCondition(seedSpendCondition), // lock_root
700
+ seed.amount, // gift
701
+ wasm.NoteData.empty(), // note_data
702
+ spend.input.note.note.hash() // parent_hash
703
+ );
704
+ spendBuilder.seed(seedObj);
705
+ }
706
+ }
707
+
708
+ // Set fee if specified
709
+ if (spend.fee > 0) {
710
+ spendBuilder.fee(spend.fee);
711
+ }
712
+
713
+ // Compute refund to balance out the spend
714
+ spendBuilder.computeRefund(false);
715
+
716
+ // Attach this spend to the main builder
717
+ builder.spend(spendBuilder);
718
+ }
719
+
720
+ // Try to build the transaction
721
+ state.builder = builder;
722
+
723
+ // Apply preimages to the builder
724
+ applyAllPreimages(builder);
725
+
726
+ // Get missing unlocks
727
+ state.missingUnlocks = getMissingUnlocks(builder);
728
+
729
+ // Try to build raw tx
730
+ try {
731
+ state.nockchainTx = builder.build();
732
+ } catch (e) {
733
+ console.log('Cannot build yet - missing unlocks:', e);
734
+ state.nockchainTx = null;
735
+ }
736
+
737
+ // Clear signed TX state as builder changed
738
+ state.signedTx = null;
739
+ state.signedTxId = null;
740
+ signedTxSection.classList.add('hidden');
741
+
742
+ renderUnlocks();
743
+ renderTransaction();
744
+ } catch (e) {
745
+ console.error('Failed to build transaction:', e);
746
+ state.builder = null;
747
+ state.nockchainTx = null;
748
+ state.missingUnlocks = [];
749
+ state.signedTx = null; // Clear signed TX state
750
+ state.signedTxId = null;
751
+ signedTxSection.classList.add('hidden');
752
+ renderUnlocks();
753
+ renderTransaction();
754
+ }
755
+ }
756
+
757
+ function applyAllPreimages(builder: wasm.TxBuilder) {
758
+ for (const [hash, jam] of state.preimages) {
759
+ try {
760
+ builder.addPreimage(jam);
761
+ } catch (e) {
762
+ // Ignore errors applying preimages
763
+ }
764
+ }
765
+ }
766
+
767
+ function getMissingUnlocks(builder: wasm.TxBuilder): any[] {
768
+ const spends = builder.allSpends();
769
+ const allMissing: any[] = [];
770
+ const seen = new Set<string>();
771
+
772
+ for (const spend of spends) {
773
+ // Get missing unlocks
774
+ const missing = spend.missingUnlocks();
775
+ for (const unlock of missing) {
776
+ // Simple dedup based on JSON stringification
777
+ const key = JSON.stringify(unlock);
778
+ if (!seen.has(key)) {
779
+ seen.add(key);
780
+ allMissing.push(unlock);
781
+ }
782
+ }
783
+
784
+ spend.free();
785
+ }
786
+
787
+ return allMissing;
788
+ }
789
+
790
+ function applyPreimages() {
791
+ if (!state.builder) return;
792
+
793
+ // Apply preimages to builder
794
+ applyAllPreimages(state.builder);
795
+
796
+ // Refresh missing unlocks
797
+ state.missingUnlocks = getMissingUnlocks(state.builder);
798
+
799
+ // Try to build again
800
+ try {
801
+ state.nockchainTx = state.builder.build();
802
+ } catch (e) {
803
+ state.nockchainTx = null;
804
+ }
805
+
806
+ // Clear signed TX state as builder changed
807
+ state.signedTx = null;
808
+ state.signedTxId = null;
809
+ signedTxSection.classList.add('hidden');
810
+ }
811
+
812
+ // ===== Preimage Management =====
813
+
814
+ function renderPreimages() {
815
+ if (state.preimages.size === 0) {
816
+ preimagesList.innerHTML = '<div style="font-size: 0.75rem; color: #6b7280">No preimages</div>';
817
+ return;
818
+ }
819
+
820
+ preimagesList.innerHTML = Array.from(state.preimages.entries())
821
+ .map(
822
+ ([hash, _]) => `
823
+ <div class="preimage-item">
824
+ <span>${hash.substring(0, 16)}...</span>
825
+ <button class="btn btn-danger btn-sm" data-action="remove-preimage" data-hash="${hash}">×</button>
826
+ </div>
827
+ `
828
+ )
829
+ .join('');
830
+ }
831
+
832
+ async function addPreimageFromFile() {
833
+ const file = preimageFileInput.files?.[0];
834
+ if (!file) {
835
+ alert('Please select a file');
836
+ return;
837
+ }
838
+
839
+ try {
840
+ const arrayBuffer = await file.arrayBuffer();
841
+ const jamBytes = new Uint8Array(arrayBuffer);
842
+
843
+ // Hash the noun to get the key
844
+ const hash = wasm.hashNoun(jamBytes);
845
+ const hashString = hash;
846
+
847
+ // Store the preimage
848
+ state.preimages.set(hashString, jamBytes);
849
+
850
+ // Clear input and close modal
851
+ preimageFileInput.value = '';
852
+ addPreimageModal.className = 'modal';
853
+
854
+ renderPreimages();
855
+ updateBuilder(); // Re-apply preimages
856
+ } catch (e) {
857
+ console.error('Failed to add preimage:', e);
858
+ alert('Failed to add preimage: ' + (e as Error).message);
859
+ }
860
+ }
861
+
862
+ // ===== Unlocks & Outputs =====
863
+
864
+ function renderUnlocks() {
865
+ if (state.missingUnlocks.length === 0) {
866
+ unlocksList.innerHTML =
867
+ '<div style="font-size: 0.75rem; color: #6b7280">No missing unlocks</div>';
868
+ return;
869
+ }
870
+
871
+ const unlocksHtml = state.missingUnlocks
872
+ .map((unlock: any) => {
873
+ // Check the type of unlock
874
+ if (unlock.Pkh) {
875
+ const sigOf = Array.from(unlock.Pkh.sig_of || []) as string[];
876
+ const needsOurSig = sigOf.includes(state.walletPkh || '');
877
+ return `
878
+ <div class="unlock-item unlock-pkh">
879
+ <div style="font-weight: 600; margin-bottom: 0.25rem">PKH Signature</div>
880
+ <div style="font-size: 0.75rem; color: #9ca3af">
881
+ Needs ${unlock.Pkh.num_sigs} signature(s)
882
+ ${needsOurSig ? '<br><span style="color: #3b82f6">⚠ Needs us to sign</span>' : ''}
883
+ </div>
884
+ ${sigOf.length > 0 ? `<div style="font-size: 0.7rem; color: #6b7280; margin-top: 0.25rem">Sign of: ${sigOf.map((s: string) => s.substring(0, 8) + '...').join(', ')}</div>` : ''}
885
+ </div>
886
+ `;
887
+ } else if (unlock.Hax) {
888
+ const preimagesFor = Array.from(unlock.Hax.preimages_for || []) as string[];
889
+ return `
890
+ <div class="unlock-item unlock-hax">
891
+ <div style="font-weight: 600; margin-bottom: 0.25rem">Hash Preimages</div>
892
+ <div style="font-size: 0.75rem; color: #9ca3af">
893
+ Missing preimages for:
894
+ </div>
895
+ ${preimagesFor.map((hash: string) => `<div style="font-size: 0.7rem; font-family: monospace; margin-top: 0.25rem">${hash.substring(0, 16)}...</div>`).join('')}
896
+ </div>
897
+ `;
898
+ } else if (unlock.Brn !== undefined) {
899
+ return `
900
+ <div class="unlock-item unlock-brn">
901
+ <div style="font-weight: 600; margin-bottom: 0.25rem">Permanently Locked</div>
902
+ <div style="font-size: 0.75rem; color: #dc2626">
903
+ This condition is permanently locked (burn)
904
+ </div>
905
+ </div>
906
+ `;
907
+ }
908
+ return '';
909
+ })
910
+ .join('');
911
+
912
+ unlocksList.innerHTML =
913
+ unlocksHtml || '<div style="font-size: 0.75rem; color: #6b7280">Unknown unlock type</div>';
914
+ }
915
+
916
+ function renderTransaction() {
917
+ if (!state.builder || !state.nockchainTx) {
918
+ txValidation.innerHTML = '';
919
+ txInfo.innerHTML = '';
920
+ outputsList.innerHTML = '';
921
+ downloadTxBtn.disabled = true;
922
+ signTxBtn.disabled = true;
923
+ signedTxSection.classList.add('hidden'); // Ensure signed section is hidden
924
+ return;
925
+ }
926
+
927
+ try {
928
+ let isValid = true;
929
+ let validationError = '';
930
+
931
+ try {
932
+ state.builder.validate();
933
+ } catch (e) {
934
+ isValid = false;
935
+ // Handle different error types - WASM might throw strings or Error objects
936
+ if (e instanceof Error) {
937
+ validationError = e.message;
938
+ } else if (typeof e === 'string') {
939
+ validationError = e;
940
+ } else {
941
+ validationError = String(e);
942
+ }
943
+ }
944
+
945
+ const fee = state.builder.curFee();
946
+ const calcFee = state.builder.calcFee();
947
+ const feeSufficient = fee >= calcFee;
948
+
949
+ // If validate() failed, it might be due to fee or missing unlocks.
950
+ // We'll show the specific error if validate failed.
951
+
952
+ txValidation.innerHTML = `
953
+ <div class="validation-status ${isValid ? 'validation-success' : 'validation-error'}">
954
+ ${isValid ? '✓ Transaction Valid' : '✗ Transaction Invalid'}
955
+ ${isValid ? '' : `<br><span style="font-size: 0.75rem">${validationError}</span>`}
956
+ ${!feeSufficient && isValid ? '<br>⚠ Insufficient fee' : ''}
957
+ </div>
958
+ `;
959
+
960
+ // Transaction Info
961
+ txInfo.innerHTML = `
962
+ <div style="background: #262626; padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem">
963
+ <div style="margin-bottom: 0.5rem">
964
+ <span style="color: #9ca3af">Fee:</span> <span style="font-weight: 600">${formatNock(nicksToNock(fee))} NOCK</span>
965
+ </div>
966
+ <div style="margin-bottom: 0.5rem">
967
+ <span style="color: #9ca3af">Calculated Fee:</span> <span>${formatNock(nicksToNock(calcFee))} NOCK</span>
968
+ </div>
969
+ <div>
970
+ <span style="color: #9ca3af">TX ID:</span> ${renderCopyableId(state.nockchainTx.id.value, 'TX ID')}
971
+ </div>
972
+ </div>
973
+ `;
974
+
975
+ // Outputs
976
+ const outputs = state.nockchainTx.outputs();
977
+ if (outputs && outputs.length > 0) {
978
+ outputsList.innerHTML = outputs
979
+ .map((output: wasm.Note, index: number) => {
980
+ const amount = output.assets || BigInt(0);
981
+
982
+ // Extract name directly from WASM object
983
+ const firstName = output.name?.first || '';
984
+ const lastName = output.name?.last || '';
985
+
986
+ return `
987
+ <div class="output-item">
988
+ <div style="font-weight: 600; margin-bottom: 0.25rem">
989
+ Output ${index + 1}: ${formatNock(nicksToNock(amount))} NOCK
990
+ </div>
991
+ <div style="font-size: 0.75rem; color: #9ca3af">
992
+ ${firstName ? renderNoteName(firstName, lastName) : 'Unknown'}
993
+ </div>
994
+ </div>
995
+ `;
996
+ })
997
+ .join('');
998
+ } else {
999
+ outputsList.innerHTML = '<div style="font-size: 0.75rem; color: #6b7280">No outputs</div>';
1000
+ }
1001
+
1002
+ // Enable download even if invalid, as requested
1003
+ downloadTxBtn.disabled = false;
1004
+
1005
+ // Sign button always enabled as requested
1006
+ signTxBtn.disabled = false;
1007
+ } catch (e) {
1008
+ console.error('Error rendering transaction:', e);
1009
+ txValidation.innerHTML =
1010
+ '<div class="validation-status validation-error">Error rendering transaction</div>';
1011
+ txInfo.innerHTML = '';
1012
+ outputsList.innerHTML = '';
1013
+ downloadTxBtn.disabled = true;
1014
+ signTxBtn.disabled = true;
1015
+ signedTxSection.classList.add('hidden'); // Ensure signed section is hidden
1016
+ }
1017
+ }
1018
+
1019
+ // ===== Event Handlers =====
1020
+
1021
+ document.addEventListener('click', (e: Event) => {
1022
+ const target = e.target as HTMLElement;
1023
+ const action = target.dataset.action;
1024
+
1025
+ if (!action) return;
1026
+
1027
+ switch (action) {
1028
+ case 'refresh':
1029
+ refreshNotesForLock(target.dataset.lockId!);
1030
+ break;
1031
+ case 'remove':
1032
+ removeLock(target.dataset.lockId!);
1033
+ break;
1034
+ case 'select-note':
1035
+ addInputToSpend(target.dataset.lockId!, parseInt(target.dataset.noteIndex!));
1036
+ break;
1037
+ case 'update-fee':
1038
+ // Handled by input event
1039
+ break;
1040
+ case 'add-seed':
1041
+ addSeed(target.dataset.inputId!);
1042
+ break;
1043
+ case 'update-seed-lock':
1044
+ case 'update-seed-amount':
1045
+ // Handled by change/input events
1046
+ break;
1047
+ case 'remove-seed':
1048
+ removeSeed(target.dataset.inputId!, parseInt(target.dataset.seedIndex!));
1049
+ break;
1050
+ case 'remove-preimage':
1051
+ state.preimages.delete(target.dataset.hash!);
1052
+ renderPreimages();
1053
+ updateBuilder();
1054
+ break;
1055
+ case 'remove-prim':
1056
+ removePrimitive(target.dataset.primId!);
1057
+ break;
1058
+ case 'remove-spend':
1059
+ removeSpend(target.dataset.inputId!);
1060
+ break;
1061
+ case 'balance-seed':
1062
+ balanceSeed(target.dataset.inputId!, parseInt(target.dataset.seedIndex!));
1063
+ break;
1064
+ }
1065
+ });
1066
+
1067
+ document.addEventListener('input', (e: Event) => {
1068
+ const target = e.target as HTMLInputElement | HTMLSelectElement;
1069
+ const action = target.dataset.action;
1070
+
1071
+ if (!action) return;
1072
+
1073
+ // For text inputs, just update state, don't re-render
1074
+ switch (action) {
1075
+ case 'update-fee':
1076
+ updateSpendFee(target.dataset.inputId!, target.value, false); // false = no re-render
1077
+ break;
1078
+ case 'update-seed-amount':
1079
+ updateSeedAmount(
1080
+ target.dataset.inputId!,
1081
+ parseInt(target.dataset.seedIndex!),
1082
+ target.value,
1083
+ false
1084
+ );
1085
+ break;
1086
+ }
1087
+ });
1088
+
1089
+ document.addEventListener('change', (e: Event) => {
1090
+ const target = e.target as HTMLInputElement | HTMLSelectElement;
1091
+ const action = target.dataset.action;
1092
+
1093
+ // Handle primitive type change
1094
+ if (action === 'update-prim-type') {
1095
+ const prim = modalPrimitives.find(p => p.id === target.dataset.primId);
1096
+ if (prim) {
1097
+ prim.type = target.value as 'pkh' | 'tim' | 'hax' | 'brn';
1098
+ // Initialize default data for the new type
1099
+ if (prim.type === 'pkh') {
1100
+ prim.pkh = { m: 1, addrs: [state.walletPkh || ''] };
1101
+ delete prim.hax;
1102
+ delete prim.tim;
1103
+ } else if (prim.type === 'hax') {
1104
+ prim.hax = { hashes: [] };
1105
+ delete prim.pkh;
1106
+ delete prim.tim;
1107
+ } else if (prim.type === 'tim') {
1108
+ prim.tim = { type: 'csv', value: 1 };
1109
+ delete prim.pkh;
1110
+ delete prim.hax;
1111
+ } else {
1112
+ delete prim.pkh;
1113
+ delete prim.hax;
1114
+ delete prim.tim;
1115
+ }
1116
+ renderLockModal(); // Re-render to show correct config options
1117
+ }
1118
+ return;
1119
+ }
1120
+
1121
+ if (!action) return;
1122
+
1123
+ // For change events (blur/enter), we can re-render to ensure consistency
1124
+ switch (action) {
1125
+ case 'update-fee':
1126
+ updateSpendFee(target.dataset.inputId!, target.value, true);
1127
+ break;
1128
+ case 'update-seed-amount':
1129
+ updateSeedAmount(
1130
+ target.dataset.inputId!,
1131
+ parseInt(target.dataset.seedIndex!),
1132
+ target.value,
1133
+ true
1134
+ );
1135
+ break;
1136
+ case 'update-seed-lock':
1137
+ updateSeedLock(target.dataset.inputId!, parseInt(target.dataset.seedIndex!), target.value);
1138
+ break;
1139
+ }
1140
+ });
1141
+
1142
+ // Lock header click
1143
+ lockList.addEventListener('click', (e: Event) => {
1144
+ const target = e.target as HTMLElement;
1145
+ if (target.classList.contains('lock-header')) {
1146
+ // Only expand if we didn't click an action button
1147
+ if (!(e.target as HTMLElement).closest('.lock-actions')) {
1148
+ const lockId = target.dataset.lockId!;
1149
+ const lock = state.locks.find(l => l.id === lockId);
1150
+ if (lock) {
1151
+ lock.expanded = !lock.expanded;
1152
+ renderLocks();
1153
+ if (lock.expanded && !state.notes.has(lockId)) {
1154
+ refreshNotesForLock(lockId);
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ });
1160
+
1161
+ // ==== Lock Modal State ====
1162
+ interface LockPrimConfig {
1163
+ id: string;
1164
+ type: 'pkh' | 'tim' | 'hax' | 'brn';
1165
+ // Data depends on type
1166
+ pkh?: {
1167
+ m: number;
1168
+ addrs: string[];
1169
+ };
1170
+ hax?: {
1171
+ hashes: string[];
1172
+ };
1173
+ tim?: {
1174
+ type: 'csv' | 'cltv'; // relative (blocks) or absolute (timestamp/height)
1175
+ value: number;
1176
+ };
1177
+ }
1178
+ const modalPrimitives: LockPrimConfig[] = [];
1179
+
1180
+ // ...
1181
+
1182
+ // ===== Add Lock Modal =====
1183
+
1184
+ function renderLockModal() {
1185
+ primitivesContainer.innerHTML = modalPrimitives
1186
+ .map((prim, index) => {
1187
+ let configHtml = '';
1188
+
1189
+ if (prim.type === 'pkh') {
1190
+ const pkh = prim.pkh || { m: 1, addrs: [''] };
1191
+ configHtml = `
1192
+ <div class="prim-config">
1193
+ <div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem">
1194
+ <label>Required Signatures (m):</label>
1195
+ <input type="number" class="input" style="width: 60px" min="1" value="${pkh.m}"
1196
+ data-action="update-pkh-m" data-prim-id="${prim.id}">
1197
+ </div>
1198
+ <div class="pkh-list">
1199
+ ${pkh.addrs
1200
+ .map(
1201
+ (addr, i) => `
1202
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 0.25rem">
1203
+ <input type="text" class="input" placeholder="PKH Address" value="${addr}"
1204
+ data-action="update-pkh-addr" data-prim-id="${prim.id}" data-addr-index="${i}" style="flex: 1">
1205
+ <button class="btn btn-danger btn-sm" data-action="remove-pkh-addr" data-prim-id="${prim.id}" data-addr-index="${i}">×</button>
1206
+ </div>
1207
+ `
1208
+ )
1209
+ .join('')}
1210
+ <button class="btn btn-sm" data-action="add-pkh-addr" data-prim-id="${prim.id}">+ Add Address</button>
1211
+ </div>
1212
+ </div>
1213
+ `;
1214
+ } else if (prim.type === 'hax') {
1215
+ const hax = prim.hax || { hashes: [] };
1216
+ configHtml = `
1217
+ <div class="prim-config">
1218
+ <div class="hax-list">
1219
+ ${hax.hashes
1220
+ .map(
1221
+ (hash, i) => `
1222
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 0.25rem">
1223
+ <input type="text" class="input" placeholder="Hash (hex)" value="${hash}"
1224
+ data-action="update-hax-hash" data-prim-id="${prim.id}" data-hash-index="${i}" style="flex: 1">
1225
+ <button class="btn btn-danger btn-sm" data-action="remove-hax-hash" data-prim-id="${prim.id}" data-hash-index="${i}">×</button>
1226
+ </div>
1227
+ `
1228
+ )
1229
+ .join('')}
1230
+ <div style="display: flex; gap: 0.5rem">
1231
+ <button class="btn btn-sm" data-action="add-hax-hash" data-prim-id="${prim.id}">+ Add Hash</button>
1232
+ <button class="btn btn-sm" data-action="upload-hax-preimage" data-prim-id="${prim.id}">Upload Preimage</button>
1233
+ </div>
1234
+ </div>
1235
+ </div>
1236
+ `;
1237
+ } else if (prim.type === 'tim') {
1238
+ const tim = prim.tim || { type: 'csv', value: 1 };
1239
+ configHtml = `
1240
+ <div class="prim-config">
1241
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem">
1242
+ <select class="input" data-action="update-tim-type" data-prim-id="${prim.id}">
1243
+ <option value="csv" ${tim.type === 'csv' ? 'selected' : ''}>Relative (Blocks)</option>
1244
+ <option value="cltv" ${tim.type === 'cltv' ? 'selected' : ''}>Absolute (Height/Time)</option>
1245
+ </select>
1246
+ <input type="number" class="input" value="${tim.value}"
1247
+ data-action="update-tim-value" data-prim-id="${prim.id}" style="flex: 1">
1248
+ </div>
1249
+ </div>
1250
+ `;
1251
+ }
1252
+
1253
+ return `
1254
+ <div class="primitive-item" style="background: #262626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 0.75rem">
1255
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center">
1256
+ <select class="input" style="flex: 1" data-prim-id="${prim.id}" data-action="update-prim-type">
1257
+ <option value="pkh" ${prim.type === 'pkh' ? 'selected' : ''}>PKH (Public Key Hash)</option>
1258
+ <option value="tim" ${prim.type === 'tim' ? 'selected' : ''}>TIM (Timelock)</option>
1259
+ <option value="hax" ${prim.type === 'hax' ? 'selected' : ''}>HAX (Hash Preimage)</option>
1260
+ <option value="brn" ${prim.type === 'brn' ? 'selected' : ''}>BRN (Burn)</option>
1261
+ </select>
1262
+ <button class="btn btn-danger btn-sm" data-action="remove-prim" data-prim-id="${prim.id}">×</button>
1263
+ </div>
1264
+ ${configHtml}
1265
+ </div>
1266
+ `;
1267
+ })
1268
+ .join('');
1269
+ }
1270
+
1271
+ function openAddLockModal() {
1272
+ modalPrimitives.length = 0;
1273
+ lockNameInput.value = '';
1274
+ renderLockModal();
1275
+ addLockModal.className = 'modal active';
1276
+ }
1277
+
1278
+ function closeAddLockModal() {
1279
+ addLockModal.className = 'modal';
1280
+ modalPrimitives.length = 0;
1281
+ }
1282
+
1283
+ function addPrimitive() {
1284
+ modalPrimitives.push({
1285
+ id: generateId(),
1286
+ type: 'pkh',
1287
+ pkh: { m: 1, addrs: [state.walletPkh || ''] },
1288
+ });
1289
+ renderLockModal();
1290
+ }
1291
+
1292
+ function removePrimitive(primId: string) {
1293
+ const index = modalPrimitives.findIndex(p => p.id === primId);
1294
+ if (index >= 0) {
1295
+ modalPrimitives.splice(index, 1);
1296
+ renderLockModal();
1297
+ }
1298
+ }
1299
+
1300
+ // Helper to get primitive by ID
1301
+ function getPrim(id: string) {
1302
+ return modalPrimitives.find(p => p.id === id);
1303
+ }
1304
+
1305
+ // Event handlers for inline config
1306
+ document.addEventListener('click', (e: Event) => {
1307
+ const target = e.target as HTMLElement;
1308
+ const action = target.dataset.action;
1309
+ if (!action) return;
1310
+
1311
+ const primId = target.dataset.primId;
1312
+ if (!primId) return;
1313
+
1314
+ const prim = getPrim(primId);
1315
+ if (!prim) return;
1316
+
1317
+ switch (action) {
1318
+ case 'add-pkh-addr':
1319
+ if (prim.pkh) {
1320
+ prim.pkh.addrs.push('');
1321
+ renderLockModal();
1322
+ }
1323
+ break;
1324
+ case 'remove-pkh-addr':
1325
+ if (prim.pkh) {
1326
+ const idx = parseInt(target.dataset.addrIndex!);
1327
+ prim.pkh.addrs.splice(idx, 1);
1328
+ renderLockModal();
1329
+ }
1330
+ break;
1331
+ case 'add-hax-hash':
1332
+ if (prim.hax) {
1333
+ prim.hax.hashes.push('');
1334
+ renderLockModal();
1335
+ }
1336
+ break;
1337
+ case 'remove-hax-hash':
1338
+ if (prim.hax) {
1339
+ const idx = parseInt(target.dataset.hashIndex!);
1340
+ prim.hax.hashes.splice(idx, 1);
1341
+ renderLockModal();
1342
+ }
1343
+ break;
1344
+ case 'upload-hax-preimage':
1345
+ // Create a hidden file input
1346
+ const input = document.createElement('input');
1347
+ input.type = 'file';
1348
+ input.onchange = async (ev: Event) => {
1349
+ const target = ev.target as HTMLInputElement;
1350
+ const file = target.files?.[0];
1351
+ if (file && prim.hax) {
1352
+ const buf = await file.arrayBuffer();
1353
+ const bytes = new Uint8Array(buf);
1354
+ const hash = wasm.hashNoun(bytes);
1355
+ const hashStr = hash;
1356
+ prim.hax.hashes.push(hashStr);
1357
+
1358
+ // Also add to global preimages
1359
+ state.preimages.set(hashStr, bytes);
1360
+ renderPreimages();
1361
+
1362
+ renderLockModal();
1363
+ }
1364
+ };
1365
+ input.click();
1366
+ break;
1367
+ }
1368
+ });
1369
+
1370
+ document.addEventListener('input', (e: Event) => {
1371
+ const target = e.target as HTMLInputElement;
1372
+ const action = target.dataset.action;
1373
+ if (!action) return;
1374
+
1375
+ const primId = target.dataset.primId;
1376
+ if (!primId) return;
1377
+
1378
+ const prim = getPrim(primId);
1379
+ if (!prim) return;
1380
+
1381
+ switch (action) {
1382
+ case 'update-pkh-m':
1383
+ if (prim.pkh) prim.pkh.m = parseInt(target.value) || 1;
1384
+ break;
1385
+ case 'update-pkh-addr':
1386
+ if (prim.pkh) prim.pkh.addrs[parseInt(target.dataset.addrIndex!)] = target.value;
1387
+ break;
1388
+ case 'update-hax-hash':
1389
+ if (prim.hax) prim.hax.hashes[parseInt(target.dataset.hashIndex!)] = target.value;
1390
+ break;
1391
+ case 'update-tim-value':
1392
+ if (prim.tim) prim.tim.value = parseInt(target.value) || 0;
1393
+ break;
1394
+ }
1395
+ });
1396
+
1397
+ document.addEventListener('change', (e: Event) => {
1398
+ const target = e.target as HTMLSelectElement;
1399
+ const action = target.dataset.action;
1400
+
1401
+ if (action === 'update-tim-type') {
1402
+ const prim = getPrim(target.dataset.primId!);
1403
+ if (prim && prim.tim) {
1404
+ prim.tim.type = target.value as 'csv' | 'cltv';
1405
+ }
1406
+ }
1407
+ });
1408
+
1409
+ function confirmAddLock() {
1410
+ const name = lockNameInput.value.trim();
1411
+ if (!name) {
1412
+ alert('Please enter a lock name');
1413
+ return;
1414
+ }
1415
+
1416
+ if (modalPrimitives.length === 0) {
1417
+ alert('Please add at least one lock primitive');
1418
+ return;
1419
+ }
1420
+
1421
+ try {
1422
+ // Build lock primitives
1423
+ const primitives: wasm.LockPrimitive[] = [];
1424
+
1425
+ for (const prim of modalPrimitives) {
1426
+ switch (prim.type) {
1427
+ case 'pkh':
1428
+ const pkhConfig = prim.pkh || { m: 1, addrs: [] };
1429
+ const validAddrs = pkhConfig.addrs.filter(a => a.trim() !== '');
1430
+
1431
+ if (validAddrs.length === 0) {
1432
+ alert('PKH requires at least one address');
1433
+ return;
1434
+ }
1435
+
1436
+ if (validAddrs.length === 1 && pkhConfig.m === 1) {
1437
+ const pkh = wasm.Pkh.single(validAddrs[0]);
1438
+ primitives.push(wasm.LockPrimitive.newPkh(pkh));
1439
+ } else {
1440
+ try {
1441
+ const pkh = new wasm.Pkh(BigInt(pkhConfig.m), validAddrs);
1442
+ primitives.push(wasm.LockPrimitive.newPkh(pkh));
1443
+ } catch (e) {
1444
+ console.error('Failed to create multisig PKH', e);
1445
+ alert('Failed to create multisig PKH: ' + (e as Error).message);
1446
+ return;
1447
+ }
1448
+ }
1449
+ break;
1450
+
1451
+ case 'tim':
1452
+ const timConfig = prim.tim || { type: 'csv', value: 1 };
1453
+ let tim;
1454
+ if (timConfig.type === 'csv') {
1455
+ // Relative timelock (CSV)
1456
+ // min=value, max=null for relative part
1457
+ const rel = new wasm.TimelockRange(BigInt(timConfig.value), null);
1458
+ const abs = new wasm.TimelockRange(null, null);
1459
+ tim = new wasm.LockTim(rel, abs);
1460
+ } else {
1461
+ // Absolute timelock (CLTV)
1462
+ // min=value, max=null for absolute part
1463
+ const rel = new wasm.TimelockRange(null, null);
1464
+ const abs = new wasm.TimelockRange(BigInt(timConfig.value), null);
1465
+ tim = new wasm.LockTim(rel, abs);
1466
+ }
1467
+ primitives.push(wasm.LockPrimitive.newTim(tim));
1468
+ break;
1469
+
1470
+ case 'hax':
1471
+ const haxConfig = prim.hax || { hashes: [] };
1472
+ const validHashes = haxConfig.hashes.filter(h => h.trim() !== '');
1473
+
1474
+ if (validHashes.length === 0) {
1475
+ // Allow empty for "any preimage"? User prompt said "leave blank to require any preimage"
1476
+ // But HAX usually requires at least one hash unless it's a specific "any" type?
1477
+ // If hashes is empty, maybe we don't add it? Or add empty Hax?
1478
+ // `new wasm.Hax([])` might work.
1479
+ alert('HAX requires at least one hash.');
1480
+ return;
1481
+ }
1482
+
1483
+ const digests = validHashes.map(h => new wasm.Digest(h));
1484
+ const hax = new wasm.Hax(digests);
1485
+ primitives.push(wasm.LockPrimitive.newHax(hax));
1486
+ break;
1487
+
1488
+ case 'brn':
1489
+ primitives.push(wasm.LockPrimitive.newBrn());
1490
+ break;
1491
+ }
1492
+ }
1493
+
1494
+ const spendCondition = new wasm.SpendCondition(primitives);
1495
+
1496
+ const newLock: Lock = {
1497
+ id: generateId(),
1498
+ name,
1499
+ spendConditionProtobuf: spendCondition.toProtobuf(),
1500
+ expanded: false,
1501
+ };
1502
+
1503
+ state.locks.push(newLock);
1504
+ spendCondition.free(); // Clean up WASM object
1505
+ renderLocks();
1506
+ closeAddLockModal();
1507
+ } catch (e) {
1508
+ console.error('Failed to create lock:', e);
1509
+ alert('Failed to create lock: ' + (e as Error).message);
1510
+ }
1511
+ }
1512
+
1513
+ // ===== Import/Export Locks =====
1514
+
1515
+ function exportLocks() {
1516
+ try {
1517
+ const locksData = state.locks.map(lock => ({
1518
+ id: lock.id,
1519
+ name: lock.name,
1520
+ spendCondition: lock.spendConditionProtobuf, // Already protobuf
1521
+ }));
1522
+
1523
+ const json = JSON.stringify(locksData, null, 2);
1524
+ const blob = new Blob([json], { type: 'application/json' });
1525
+ const url = URL.createObjectURL(blob);
1526
+ const a = document.createElement('a');
1527
+ a.href = url;
1528
+ a.download = 'locks.json';
1529
+ document.body.appendChild(a);
1530
+ a.click();
1531
+ document.body.removeChild(a);
1532
+ URL.revokeObjectURL(url);
1533
+
1534
+ console.log('Exported locks');
1535
+ } catch (e) {
1536
+ console.error('Failed to export locks:', e);
1537
+ alert('Failed to export locks');
1538
+ }
1539
+ }
1540
+
1541
+ function importLocks() {
1542
+ const input = document.createElement('input');
1543
+ input.type = 'file';
1544
+ input.accept = '.json';
1545
+ input.onchange = async (e: Event) => {
1546
+ const target = e.target as HTMLInputElement;
1547
+ const file = target?.files?.[0];
1548
+ if (!file) return;
1549
+
1550
+ try {
1551
+ const text = await file.text();
1552
+ const locksData = JSON.parse(text);
1553
+
1554
+ if (!Array.isArray(locksData)) {
1555
+ alert('Invalid locks file');
1556
+ return;
1557
+ }
1558
+
1559
+ let importedCount = 0;
1560
+ for (const lockData of locksData) {
1561
+ // Validate it's indeed a spend condition by deserializing
1562
+ const spendCondition = wasm.SpendCondition.fromProtobuf(lockData.spendCondition);
1563
+ const hash = spendCondition.hash().value;
1564
+
1565
+ // Check for duplicates by hash
1566
+ const exists = state.locks.some(l => {
1567
+ const existingSc = wasm.SpendCondition.fromProtobuf(l.spendConditionProtobuf);
1568
+ const existingHash = existingSc.hash().value;
1569
+ existingSc.free();
1570
+ return existingHash === hash;
1571
+ });
1572
+
1573
+ if (exists) {
1574
+ console.log(`Skipping duplicate lock: ${lockData.name} (${hash})`);
1575
+ spendCondition.free();
1576
+ continue;
1577
+ }
1578
+
1579
+ const lock: Lock = {
1580
+ id: lockData.id || generateId(),
1581
+ name: lockData.name || 'Imported Lock',
1582
+ spendConditionProtobuf: lockData.spendCondition, // Store as protobuf
1583
+ expanded: false,
1584
+ };
1585
+ state.locks.push(lock);
1586
+ spendCondition.free();
1587
+ importedCount++;
1588
+ }
1589
+
1590
+ renderLocks();
1591
+ console.log(`Imported ${importedCount} locks`);
1592
+ if (importedCount < locksData.length) {
1593
+ console.log(
1594
+ `Imported ${importedCount} locks. Skipped ${locksData.length - importedCount} duplicates.`
1595
+ );
1596
+ }
1597
+ } catch (e) {
1598
+ console.error('Failed to import locks:', e);
1599
+ alert('Failed to import locks: ' + (e as Error).message);
1600
+ }
1601
+ };
1602
+ input.click();
1603
+ }
1604
+
1605
+ // Buttons
1606
+ addLockBtn.onclick = () => openAddLockModal();
1607
+ closeLockModal.onclick = cancelAddLockBtn.onclick = () => closeAddLockModal();
1608
+ addPrimitiveBtn.onclick = () => addPrimitive();
1609
+ confirmAddLockBtn.onclick = () => confirmAddLock();
1610
+ importLocksBtn.onclick = () => importLocks();
1611
+ exportLocksBtn.onclick = () => exportLocks();
1612
+
1613
+ refreshAllBtn.onclick = () => refreshAllNotes();
1614
+ addPreimageBtn.onclick = () => (addPreimageModal.className = 'modal active');
1615
+ closePreimageModal.onclick = cancelPreimageBtn.onclick = () =>
1616
+ (addPreimageModal.className = 'modal');
1617
+ addPreimageConfirmBtn.onclick = () => addPreimageFromFile();
1618
+
1619
+ downloadTxBtn.onclick = () => {
1620
+ if (!state.nockchainTx) return;
1621
+
1622
+ try {
1623
+ const jamBytes = state.nockchainTx.toJam();
1624
+ const txId = state.nockchainTx.id.value;
1625
+
1626
+ const blob = new Blob([new Uint8Array(jamBytes)], { type: 'application/jam' });
1627
+ const url = URL.createObjectURL(blob);
1628
+ const a = document.createElement('a');
1629
+ a.href = url;
1630
+ a.download = `${txId}.tx`;
1631
+ document.body.appendChild(a);
1632
+ a.click();
1633
+ document.body.removeChild(a);
1634
+ URL.revokeObjectURL(url);
1635
+
1636
+ console.log('Downloaded unsigned transaction:', txId);
1637
+ } catch (e) {
1638
+ console.error('Failed to download transaction:', e);
1639
+ alert('Failed to download transaction');
1640
+ }
1641
+ };
1642
+
1643
+ signTxBtn.onclick = async () => {
1644
+ if (!state.nockchainTx || !state.builder || !state.provider) return;
1645
+
1646
+ try {
1647
+ // Get all notes and spend conditions from builder
1648
+ const txNotes = state.builder.allNotes();
1649
+
1650
+ // Sign using provider
1651
+ const signedTxProtobuf = await state.provider.signRawTx({
1652
+ rawTx: state.nockchainTx.toRawTx(),
1653
+ notes: txNotes.notes,
1654
+ spendConditions: txNotes.spendConditions,
1655
+ });
1656
+
1657
+ // Store signed TX
1658
+ // NockchainTx doesn't have fromProtobuf, so we go via RawTx
1659
+ const signedRawTx = wasm.RawTx.fromProtobuf(signedTxProtobuf);
1660
+ state.signedTx = signedRawTx.toNockchainTx();
1661
+
1662
+ // Validate the signed transaction
1663
+ console.log('Validating signed transaction...');
1664
+ let isValid = true;
1665
+ let validationError = '';
1666
+
1667
+ try {
1668
+ const signedBuilder = wasm.TxBuilder.fromTx(
1669
+ state.signedTx.toRawTx(),
1670
+ txNotes.notes,
1671
+ txNotes.spendConditions
1672
+ );
1673
+ signedBuilder.validate();
1674
+ signedBuilder.free();
1675
+ console.log('Signed transaction validated successfully');
1676
+ } catch (e) {
1677
+ console.error('Signed transaction validation failed:', e);
1678
+ isValid = false;
1679
+ validationError = e instanceof Error ? e.message : String(e);
1680
+ }
1681
+
1682
+ console.log(state.signedTx.toRawTx().toProtobuf());
1683
+ if (state.signedTx) {
1684
+ state.signedTxId = state.signedTx.id.value;
1685
+ // Show signed TX section
1686
+ signedTxSection.classList.remove('hidden');
1687
+
1688
+ // Render TX ID
1689
+ signedTxIdEl.innerHTML = renderCopyableId(state.signedTxId ?? '', 'Signed TX ID');
1690
+
1691
+ // Render Validation Status
1692
+ const signedTxValidation = document.getElementById('signedTxValidation');
1693
+ if (signedTxValidation) {
1694
+ signedTxValidation.innerHTML = `
1695
+ <div class="validation-status ${isValid ? 'validation-success' : 'validation-error'}">
1696
+ ${isValid ? '✓ Transaction Valid' : '✗ Transaction Invalid'}
1697
+ ${isValid ? '' : `<br><span style="font-size: 0.75rem">${validationError}</span>`}
1698
+ </div>
1699
+ `;
1700
+ }
1701
+
1702
+ const submitBtn = document.getElementById('submitSignedTxBtn') as HTMLButtonElement;
1703
+ if (submitBtn) {
1704
+ submitBtn.disabled = !isValid;
1705
+ submitBtn.title = isValid ? '' : 'Transaction is invalid';
1706
+ }
1707
+
1708
+ console.log('Transaction signed:', state.signedTxId);
1709
+ }
1710
+ } catch (e) {
1711
+ console.error('Failed to sign transaction:', e);
1712
+ alert('Failed to sign transaction: ' + (e instanceof Error ? e.message : String(e)));
1713
+ }
1714
+ };
1715
+
1716
+ // Post-Sign Handlers
1717
+ document.getElementById('downloadSignedTxBtn')!.onclick = () => {
1718
+ if (!state.signedTx || !state.signedTxId) return;
1719
+
1720
+ try {
1721
+ const jamBytes = state.signedTx.toJam();
1722
+ const blob = new Blob([new Uint8Array(jamBytes)], { type: 'application/jam' });
1723
+ const url = URL.createObjectURL(blob);
1724
+ const a = document.createElement('a');
1725
+ a.href = url;
1726
+ a.download = `${state.signedTxId}-signed.tx`;
1727
+ document.body.appendChild(a);
1728
+ a.click();
1729
+ document.body.removeChild(a);
1730
+ URL.revokeObjectURL(url);
1731
+
1732
+ const rawTxBytes = state.signedTx.toRawTx().toJam();
1733
+ const blobRaw = new Blob([new Uint8Array(rawTxBytes)], { type: 'application/jam' });
1734
+ const urlRaw = URL.createObjectURL(blobRaw);
1735
+ const aRaw = document.createElement('a');
1736
+ aRaw.href = urlRaw;
1737
+ aRaw.download = `${state.signedTxId}-signed.raw.jam`;
1738
+ document.body.appendChild(aRaw);
1739
+ aRaw.click();
1740
+ document.body.removeChild(aRaw);
1741
+ URL.revokeObjectURL(urlRaw);
1742
+ } catch (e) {
1743
+ console.error('Failed to download signed tx:', e);
1744
+ alert('Failed to download signed tx');
1745
+ }
1746
+ };
1747
+
1748
+ document.getElementById('submitSignedTxBtn')!.onclick = async () => {
1749
+ if (!state.signedTx) return;
1750
+
1751
+ try {
1752
+ if (!state.grpcClient) throw new Error('gRPC client not initialized');
1753
+
1754
+ // Convert to protobuf for sending
1755
+ const txProtobuf = state.signedTx.toRawTx().toProtobuf();
1756
+ await state.grpcClient.sendTransaction(txProtobuf);
1757
+
1758
+ console.log('Transaction submitted successfully!');
1759
+ } catch (e) {
1760
+ console.error('Failed to submit transaction:', e);
1761
+ alert('Failed to submit transaction: ' + (e as Error).message);
1762
+ }
1763
+ };
1764
+
1765
+ // Initialize
1766
+ init();