@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.
- package/.github/workflows/artifacts.yml +33 -0
- package/.github/workflows/ci.yml +68 -0
- package/.github/workflows/publish-sdk.yml +35 -0
- package/.nvmrc +1 -0
- package/.prettierignore +5 -0
- package/.prettierrc +8 -0
- package/LICENSE +22 -0
- package/README.md +117 -0
- package/extension/background/index.ts +1500 -0
- package/extension/content/index.ts +59 -0
- package/extension/icons/rose.svg +27 -0
- package/extension/icons/rose128.png +0 -0
- package/extension/icons/rose16.png +0 -0
- package/extension/icons/rose256.png +0 -0
- package/extension/icons/rose32.png +0 -0
- package/extension/icons/rose48.png +0 -0
- package/extension/icons/rose512.png +0 -0
- package/extension/inpage/index.ts +86 -0
- package/extension/manifest.json +48 -0
- package/extension/popup/Popup.tsx +94 -0
- package/extension/popup/Router.tsx +121 -0
- package/extension/popup/assets/arrow-down-icon.svg +3 -0
- package/extension/popup/assets/arrow-left-icon.svg +3 -0
- package/extension/popup/assets/arrow-right-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
- package/extension/popup/assets/checkmark-icon.svg +3 -0
- package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
- package/extension/popup/assets/checkmark-success-icon.svg +3 -0
- package/extension/popup/assets/clock-icon.svg +3 -0
- package/extension/popup/assets/close-x-icon.svg +3 -0
- package/extension/popup/assets/copy-icon.svg +6 -0
- package/extension/popup/assets/explorer-icon.svg +3 -0
- package/extension/popup/assets/eye-off-icon.svg +3 -0
- package/extension/popup/assets/eye-open-icon.svg +4 -0
- package/extension/popup/assets/feedback-icon.svg +3 -0
- package/extension/popup/assets/green-status-dot.svg +3 -0
- package/extension/popup/assets/info-icon.svg +3 -0
- package/extension/popup/assets/iris-logo-40.svg +27 -0
- package/extension/popup/assets/iris-logo-96.svg +27 -0
- package/extension/popup/assets/iris-logo-blue.svg +27 -0
- package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
- package/extension/popup/assets/iris-logo-orange.svg +27 -0
- package/extension/popup/assets/iris-logo.svg +27 -0
- package/extension/popup/assets/key-icon.svg +3 -0
- package/extension/popup/assets/lock-icon-yellow.svg +3 -0
- package/extension/popup/assets/lock-icon.svg +3 -0
- package/extension/popup/assets/pencil-edit-icon.svg +3 -0
- package/extension/popup/assets/permissions-icon.svg +3 -0
- package/extension/popup/assets/receipt-icon.svg +5 -0
- package/extension/popup/assets/refresh-icon.svg +3 -0
- package/extension/popup/assets/settings-gear-icon.svg +8 -0
- package/extension/popup/assets/settings-icon.svg +3 -0
- package/extension/popup/assets/theme-icon.svg +3 -0
- package/extension/popup/assets/trash-bin-icon.svg +3 -0
- package/extension/popup/assets/trend-down-arrow.svg +5 -0
- package/extension/popup/assets/trend-up-arrow.svg +5 -0
- package/extension/popup/assets/user-account-icon.svg +3 -0
- package/extension/popup/assets/vector-bottom-left.svg +9 -0
- package/extension/popup/assets/vector-left.svg +9 -0
- package/extension/popup/assets/vector-right.svg +9 -0
- package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
- package/extension/popup/assets/vector-top-right.svg +9 -0
- package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
- package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
- package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
- package/extension/popup/components/AccountIcon.tsx +78 -0
- package/extension/popup/components/AccountSelector.tsx +246 -0
- package/extension/popup/components/Alert.tsx +48 -0
- package/extension/popup/components/ConfirmModal.tsx +81 -0
- package/extension/popup/components/PasswordInput.tsx +49 -0
- package/extension/popup/components/ScreenContainer.tsx +17 -0
- package/extension/popup/components/SiteIcon.tsx +60 -0
- package/extension/popup/components/ThemeToggle.tsx +44 -0
- package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
- package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
- package/extension/popup/components/icons/CheckIcon.tsx +20 -0
- package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
- package/extension/popup/components/icons/CloseIcon.tsx +26 -0
- package/extension/popup/components/icons/CopyIcon.tsx +20 -0
- package/extension/popup/components/icons/EditIcon.tsx +20 -0
- package/extension/popup/components/icons/EyeIcon.tsx +13 -0
- package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
- package/extension/popup/components/icons/InfoIcon.tsx +20 -0
- package/extension/popup/components/icons/LockIcon.tsx +20 -0
- package/extension/popup/components/icons/PlusIcon.tsx +15 -0
- package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
- package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
- package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
- package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
- package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
- package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
- package/extension/popup/components/icons/UploadIcon.tsx +20 -0
- package/extension/popup/components/icons/WalletIcon.tsx +20 -0
- package/extension/popup/contexts/ThemeContext.tsx +105 -0
- package/extension/popup/hooks/useApprovalDetection.ts +128 -0
- package/extension/popup/hooks/useAutoFocus.ts +36 -0
- package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
- package/extension/popup/hooks/useClickOutside.ts +33 -0
- package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
- package/extension/popup/hooks/useFavicon.ts +64 -0
- package/extension/popup/hooks/useNumericInput.ts +93 -0
- package/extension/popup/index.html +13 -0
- package/extension/popup/index.tsx +24 -0
- package/extension/popup/screens/AboutScreen.tsx +118 -0
- package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
- package/extension/popup/screens/HomeScreen.tsx +902 -0
- package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
- package/extension/popup/screens/LockTimeScreen.tsx +155 -0
- package/extension/popup/screens/ReceiveScreen.tsx +149 -0
- package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
- package/extension/popup/screens/SendReviewScreen.tsx +308 -0
- package/extension/popup/screens/SendScreen.tsx +825 -0
- package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
- package/extension/popup/screens/SettingsScreen.tsx +116 -0
- package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
- package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
- package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
- package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
- package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
- package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
- package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
- package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
- package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
- package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
- package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
- package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
- package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
- package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
- package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
- package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
- package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
- package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
- package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
- package/extension/popup/screens/system/LockedScreen.tsx +288 -0
- package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
- package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
- package/extension/popup/store.ts +482 -0
- package/extension/popup/styles.css +246 -0
- package/extension/popup/utils/format.ts +58 -0
- package/extension/popup/utils/formatWalletError.ts +36 -0
- package/extension/popup/utils/memo.ts +299 -0
- package/extension/popup/utils/messaging.ts +16 -0
- package/extension/shared/address-encoding.ts +69 -0
- package/extension/shared/balance-query.ts +123 -0
- package/extension/shared/constants.ts +386 -0
- package/extension/shared/currency.ts +128 -0
- package/extension/shared/first-name-derivation.ts +128 -0
- package/extension/shared/keyfile.ts +58 -0
- package/extension/shared/onboarding.ts +78 -0
- package/extension/shared/price-api.ts +79 -0
- package/extension/shared/rpc-client-browser.ts +315 -0
- package/extension/shared/transaction-builder.ts +443 -0
- package/extension/shared/types.ts +450 -0
- package/extension/shared/utxo-diff.ts +212 -0
- package/extension/shared/utxo-store.ts +548 -0
- package/extension/shared/utxo-sync.ts +343 -0
- package/extension/shared/validators.ts +26 -0
- package/extension/shared/vault.ts +1580 -0
- package/extension/shared/wallet-crypto.ts +77 -0
- package/extension/shared/wasm-utils.ts +76 -0
- package/extension/shared/webcrypto.ts +67 -0
- package/extension/types/wasm.d.ts +13 -0
- package/package.json +39 -0
- package/postcss.config.js +6 -0
- package/rose-extension-dist.zip +0 -0
- package/sdk/README.md +88 -0
- package/sdk/examples/app.ts +166 -0
- package/sdk/examples/index.html +51 -0
- package/sdk/examples/tsconfig.json +15 -0
- package/sdk/examples/tx-builder.html +532 -0
- package/sdk/examples/tx-builder.ts +1766 -0
- package/sdk/package-lock.json +424 -0
- package/sdk/package.json +68 -0
- package/sdk/src/constants.ts +28 -0
- package/sdk/src/errors.ts +74 -0
- package/sdk/src/hooks/index.ts +1 -0
- package/sdk/src/hooks/use-rose.ts +94 -0
- package/sdk/src/index.ts +12 -0
- package/sdk/src/provider.ts +396 -0
- package/sdk/src/transaction.ts +163 -0
- package/sdk/src/types/rose-wasm.d.ts +14 -0
- package/sdk/src/types.ts +97 -0
- package/sdk/src/wasm.ts +13 -0
- package/sdk/tsconfig.json +20 -0
- package/sdk/vite.config.examples.ts +32 -0
- package/tailwind.config.ts +38 -0
- package/tsconfig.json +20 -0
- 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, '"').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();
|