Skip to content

Commit 175a069

Browse files
committed
win installer - minor fixes
1 parent df1ed3c commit 175a069

24 files changed

Lines changed: 1976 additions & 121 deletions

Makefile

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
.PHONY: up down restart nuke restart-gui build logs dev dev-build wallet-cli nft-images-public
1+
.PHONY: up down restart nuke restart-gui build logs dev dev-build dev-local wallet-cli nft-images-public pending-transactions list-utxos
2+
3+
ACCOUNT ?= 0
24

35
## Start all services
46
up:
@@ -48,6 +50,10 @@ dev:
4850
docker compose --profile indexer -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans 2>/dev/null || true
4951
docker compose --profile indexer -f docker-compose.yml -f docker-compose.dev.yml up --build
5052

53+
## Like dev, but uses locally-built core images (run ./build-core-images.sh first)
54+
dev-local:
55+
docker compose --profile indexer -f docker-compose.yml -f docker-compose.dev.yml up
56+
5157
## Rebuild dev image only (run after adding npm packages, then re-run make dev)
5258
dev-build:
5359
docker compose --profile indexer -f docker-compose.yml -f docker-compose.dev.yml build web-gui
@@ -56,6 +62,26 @@ dev-build:
5662
wallet-cli:
5763
docker compose --profile wallet_cli run --rm wallet-cli
5864

65+
## List pending transactions for account ACCOUNT (default 0) via wallet RPC.
66+
## Usage: make pending-transactions or make pending-transactions ACCOUNT=1
67+
pending-transactions:
68+
docker run --rm \
69+
--network web-gui_default \
70+
-v "$(CURDIR)/tools:/tools:ro" \
71+
-v "$(CURDIR)/.env:/.env:ro" \
72+
-e WALLET_RPC_HOST=wallet-rpc-daemon \
73+
alpine sh -c 'apk add -q bash curl jq >/dev/null && bash /tools/list-pending-transactions.sh $(ACCOUNT)'
74+
75+
## List UTXOs for account ACCOUNT (default 0) via wallet RPC.
76+
## Usage: make list-utxos or make list-utxos ACCOUNT=1
77+
list-utxos:
78+
docker run --rm \
79+
--network web-gui_default \
80+
-v "$(CURDIR)/tools:/tools:ro" \
81+
-v "$(CURDIR)/.env:/.env:ro" \
82+
-e WALLET_RPC_HOST=wallet-rpc-daemon \
83+
alpine sh -c 'apk add -q bash curl jq >/dev/null && bash /tools/list-utxos.sh $(ACCOUNT)'
84+
5985
## Ensure all NFT images stored on Pinata are publicly accessible.
6086
## Runs inside Docker so it can reach wallet-rpc-daemon on the internal network.
6187
nft-images-public:

app/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "mintlayer-web-gui",
33
"type": "module",
4-
"version": "0.99.7",
4+
"version": "0.99.8",
55
"scripts": {
66
"dev": "astro dev",
77
"build": "astro build",

app/src/components/CommandPalette.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,15 @@ export default function CommandPalette({ items }: Props) {
9494
return result;
9595
}, [displayItems]);
9696

97-
// Flat ordered list for keyboard nav
98-
const flat = useMemo(() => displayItems, [displayItems]);
97+
// Flat ordered list for keyboard nav — must match visual section-grouped render order
98+
const flat = useMemo(() => {
99+
const order = ['WALLET', 'ASSETS', 'TRADE', 'PLUGINS'];
100+
const secs = [
101+
...order.filter(s => grouped[s]),
102+
...Object.keys(grouped).filter(s => !order.includes(s)),
103+
];
104+
return secs.flatMap(s => grouped[s] ?? []);
105+
}, [grouped]);
99106

100107
const navigate = useCallback((item: NavItem) => {
101108
setOpen(false);

app/src/components/IssueTokenModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop
115115
}
116116

117117
let newTokenId = '';
118-
await submitWithToast(async () => {
118+
const newTxId = await submitWithToast(async () => {
119119
const res = await rpc<{ token_id: string; tx_id: string }>('token_issue_new', {
120120
account: 0,
121121
destination_address: destAddress,
@@ -136,8 +136,8 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop
136136

137137
// Persist to localStorage so "My Issued Tokens" can show it even at zero balance
138138
try {
139-
const stored = JSON.parse(localStorage.getItem('ml_issued_tokens') ?? '[]') as Array<{ tokenId: string; ticker: string; decimals: number; issuedAt: number }>;
140-
stored.unshift({ tokenId: newTokenId, ticker, decimals, issuedAt: Date.now() });
139+
const stored = JSON.parse(localStorage.getItem('ml_issued_tokens') ?? '[]') as Array<{ tokenId: string; ticker: string; decimals: number; issuedAt: number; txId?: string }>;
140+
stored.unshift({ tokenId: newTokenId, ticker, decimals, issuedAt: Date.now(), txId: newTxId });
141141
localStorage.setItem('ml_issued_tokens', JSON.stringify(stored));
142142
} catch { /* ignore - non-critical */ }
143143
} catch (err) {

app/src/components/IssuedTokensPanel.tsx

Lines changed: 157 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface StoredToken {
1111
ticker: string;
1212
decimals: number;
1313
issuedAt: number;
14+
txId?: string;
1415
}
1516

1617
type FungibleContent = {
@@ -32,6 +33,10 @@ interface Props {
3233
network: string;
3334
}
3435

36+
// ── Constants ─────────────────────────────────────────────────────────────────
37+
38+
const TEN_MINUTES = 10 * 60 * 1000;
39+
3540
// ── Helpers ────────────────────────────────────────────────────────────────────
3641

3742
const LS_KEY = 'ml_issued_tokens';
@@ -45,7 +50,10 @@ function loadStored(): StoredToken[] {
4550
}
4651

4752
function saveStored(list: StoredToken[]) {
48-
localStorage.setItem(LS_KEY, JSON.stringify(list));
53+
// Deduplicate by tokenId before persisting
54+
const seen = new Set<string>();
55+
const deduped = list.filter(t => !seen.has(t.tokenId) && seen.add(t.tokenId));
56+
localStorage.setItem(LS_KEY, JSON.stringify(deduped));
4957
}
5058

5159
async function rpc<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
@@ -83,6 +91,7 @@ export default function IssuedTokensPanel({ network }: Props) {
8391
const [liveInfos, setLiveInfos] = useState<Map<string, TokenLiveInfo>>(new Map());
8492
const [loading, setLoading] = useState(true);
8593
const [manageTokenId, setManageTokenId] = useState<string | null>(null);
94+
const [failedTokenIds, setFailedTokenIds] = useState<Set<string>>(new Set());
8695

8796
const explorerBase = network === 'testnet'
8897
? 'https://lovelace.explorer.mintlayer.org'
@@ -96,18 +105,62 @@ export default function IssuedTokensPanel({ network }: Props) {
96105
return map;
97106
}
98107

108+
async function checkFailedTxs(tokens: StoredToken[], map: Map<string, TokenLiveInfo>) {
109+
const pendingWithTx = tokens.filter(t => map.get(t.tokenId) === null && t.txId);
110+
if (pendingWithTx.length === 0) return;
111+
try {
112+
const pendingTxIds = await rpc<string[]>('transaction_list_pending', { account: 0 });
113+
const pendingSet = new Set(pendingTxIds);
114+
const failed = pendingWithTx.filter(t => !pendingSet.has(t.txId!));
115+
if (failed.length > 0) {
116+
setFailedTokenIds(prev => {
117+
const next = new Set(prev);
118+
failed.forEach(t => next.add(t.tokenId));
119+
return next;
120+
});
121+
}
122+
} catch { /* ignore - best effort */ }
123+
}
124+
99125
async function loadLiveInfos(tokens: StoredToken[]) {
100126
if (tokens.length === 0) { setLoading(false); return; }
101127
try {
102128
const map = await fetchLiveInfos(tokens.map(t => t.tokenId));
103129
setLiveInfos(map);
130+
sweepStaleTokens(tokens, map);
131+
await checkFailedTxs(tokens, map);
104132
} catch {
105133
// silently fail - we still show stored data
106134
} finally {
107135
setLoading(false);
108136
}
109137
}
110138

139+
function sweepStaleTokens(tokens: StoredToken[], map: Map<string, TokenLiveInfo>) {
140+
const now = Date.now();
141+
const staleIds = new Set(
142+
tokens
143+
.filter(t => map.get(t.tokenId) === null && t.issuedAt > 0 && now - t.issuedAt > TEN_MINUTES)
144+
.map(t => t.tokenId)
145+
);
146+
if (staleIds.size === 0) return;
147+
setStored(prev => {
148+
const updated = prev.filter(t => !staleIds.has(t.tokenId));
149+
saveStored(updated);
150+
return updated;
151+
});
152+
setLiveInfos(prev => { const next = new Map(prev); staleIds.forEach(id => next.delete(id)); return next; });
153+
setFailedTokenIds(prev => { const next = new Set(prev); staleIds.forEach(id => next.delete(id)); return next; });
154+
}
155+
156+
function removeToken(tokenId: string) {
157+
const updated = stored.filter(t => t.tokenId !== tokenId);
158+
saveStored(updated);
159+
setStored(updated);
160+
setLiveInfos(prev => { const next = new Map(prev); next.delete(tokenId); return next; });
161+
setFailedTokenIds(prev => { const next = new Set(prev); next.delete(tokenId); return next; });
162+
}
163+
111164
/** Scan the indexer for tokens where any of our wallet addresses is authority. */
112165
async function augmentFromIndexer(current: StoredToken[]) {
113166
try {
@@ -122,8 +175,11 @@ export default function IssuedTokensPanel({ network }: Props) {
122175
);
123176
if (addresses.length === 0) return;
124177

125-
const addrList = addresses.map(a => a.address).join(',');
126-
const res = await fetch(`/api/token-authority?addresses=${encodeURIComponent(addrList)}`);
178+
const res = await fetch('/api/token-authority', {
179+
method: 'POST',
180+
headers: { 'Content-Type': 'application/json' },
181+
body: JSON.stringify({ addresses: addresses.map(a => a.address) }),
182+
});
127183
const data = await res.json() as { ok: boolean; result?: string[]; error?: string };
128184
if (!data.ok || !data.result) return;
129185

@@ -137,7 +193,7 @@ export default function IssuedTokensPanel({ network }: Props) {
137193
const info = infoMap.get(id);
138194
const ticker = info?.type === 'FungibleToken' ? (hexToText(info.content.token_ticker) ?? '???') : '???';
139195
const decimals = info?.type === 'FungibleToken' ? info.content.number_of_decimals : 0;
140-
return { tokenId: id, ticker, decimals, issuedAt: 0 };
196+
return { tokenId: id, ticker, decimals, issuedAt: Date.now() };
141197
});
142198

143199
const merged = [...current, ...newEntries];
@@ -153,11 +209,76 @@ export default function IssuedTokensPanel({ network }: Props) {
153209
}
154210
}
155211

212+
/**
213+
* Scan the wallet's UTXO set for IssueFungibleToken outputs.
214+
* Works without the indexer — falls back to this when the indexer is not running.
215+
*/
216+
async function augmentFromWallet(current: StoredToken[]) {
217+
try {
218+
const res = await fetch('/api/scan-issued-tokens');
219+
const data = await res.json() as {
220+
ok: boolean;
221+
fungible?: Array<{ tokenId: string; ticker: string; decimals: number }>;
222+
};
223+
if (!data.ok || !data.fungible || data.fungible.length === 0) return;
224+
225+
// Use current to filter what's already known at call time
226+
const knownIds = new Set(current.map(t => t.tokenId));
227+
const newEntries: StoredToken[] = data.fungible
228+
.filter(t => !knownIds.has(t.tokenId))
229+
.map(t => ({ tokenId: t.tokenId, ticker: t.ticker, decimals: t.decimals, issuedAt: Date.now() }));
230+
231+
if (newEntries.length === 0) return;
232+
233+
const infoMap = await fetchLiveInfos(newEntries.map(t => t.tokenId));
234+
235+
// Use functional updater to merge with whatever state is current (avoids races with augmentFromIndexer)
236+
setStored(prev => {
237+
const existingIds = new Set(prev.map(t => t.tokenId));
238+
const addable = newEntries.filter(t => !existingIds.has(t.tokenId));
239+
if (addable.length === 0) return prev;
240+
const merged = [...prev, ...addable];
241+
saveStored(merged);
242+
return merged;
243+
});
244+
setLiveInfos(prev => {
245+
const next = new Map(prev);
246+
infoMap.forEach((v, k) => next.set(k, v));
247+
return next;
248+
});
249+
} catch {
250+
// UTXO augmentation is best-effort - don't surface errors
251+
}
252+
}
253+
156254
useEffect(() => {
157255
const list = loadStored();
158256
setStored(list);
159257
loadLiveInfos(list);
160258
augmentFromIndexer(list);
259+
augmentFromWallet(list);
260+
261+
const es = new EventSource('/api/block-stream');
262+
es.onmessage = (e) => {
263+
try {
264+
const msg = JSON.parse(e.data as string) as { type: string };
265+
if (msg.type === 'NewBlock') {
266+
setLiveInfos(current => {
267+
const hasPending = [...current.values()].some(v => v === null);
268+
if (hasPending) loadLiveInfos(loadStored());
269+
return current;
270+
});
271+
}
272+
} catch { /* ignore */ }
273+
};
274+
275+
const resyncInterval = setInterval(() => {
276+
const list = loadStored();
277+
loadLiveInfos(list);
278+
augmentFromWallet(list);
279+
}, 2 * 60 * 1000);
280+
281+
return () => { es.close(); clearInterval(resyncInterval); };
161282
}, []);
162283

163284
function refresh() {
@@ -171,7 +292,7 @@ export default function IssuedTokensPanel({ network }: Props) {
171292
if (stored.length === 0) {
172293
return (
173294
<p className="text-sm text-gray-500">
174-
No tokens issued from this browser yet. Use <strong className="text-gray-400">Mint Token</strong> above to create one.
295+
No authoritable tokens found. Use <strong className="text-gray-400">Mint Token</strong> above to create one.
175296
</p>
176297
);
177298
}
@@ -197,6 +318,8 @@ export default function IssuedTokensPanel({ network }: Props) {
197318
const circulating = content ? atomsToDecimal(content.circulating_supply.atoms, decimals) : '-';
198319
const badges = content ? supplyBadge(content) : [];
199320
const supplyType = content?.total_supply.type ?? '-';
321+
const isPending = live === null;
322+
const isFailed = failedTokenIds.has(token.tokenId);
200323

201324
return (
202325
<tr key={token.tokenId} className="bg-gray-900 hover:bg-gray-800/60 transition-colors">
@@ -214,6 +337,16 @@ export default function IssuedTokensPanel({ network }: Props) {
214337
{b}
215338
</span>
216339
))}
340+
{isPending && !isFailed && (
341+
<span className="text-xs rounded px-1.5 py-0.5 border bg-yellow-900/40 text-yellow-300 border-yellow-800">
342+
pending
343+
</span>
344+
)}
345+
{isFailed && (
346+
<span className="text-xs rounded px-1.5 py-0.5 border bg-red-900/40 text-red-300 border-red-800">
347+
tx failed
348+
</span>
349+
)}
217350
</span>
218351
</td>
219352
<td className="px-4 py-3">
@@ -235,12 +368,25 @@ export default function IssuedTokensPanel({ network }: Props) {
235368
)}
236369
</td>
237370
<td className="px-4 py-3">
238-
<button
239-
onClick={() => setManageTokenId(token.tokenId)}
240-
className="rounded-lg bg-mint-700/30 hover:bg-mint-700/60 border border-mint-800 px-3 py-1 text-xs font-semibold text-mint-300 transition-colors"
241-
>
242-
Manage
243-
</button>
371+
{isPending ? (
372+
<button
373+
onClick={() => removeToken(token.tokenId)}
374+
className={`rounded-lg border px-3 py-1 text-xs font-semibold transition-colors ${
375+
isFailed
376+
? 'border-red-800 text-red-400 hover:bg-red-900/30'
377+
: 'border-gray-700 text-gray-400 hover:bg-gray-800/50'
378+
}`}
379+
>
380+
Remove
381+
</button>
382+
) : (
383+
<button
384+
onClick={() => setManageTokenId(token.tokenId)}
385+
className="rounded-lg border bg-mint-700/30 hover:bg-mint-700/60 border-mint-800 text-mint-300 px-3 py-1 text-xs font-semibold transition-colors"
386+
>
387+
Manage
388+
</button>
389+
)}
244390
</td>
245391
</tr>
246392
);

app/src/components/TokenManagePanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,12 @@ function useAction() {
9191
setMsg('Transaction submitted.');
9292
} catch (err) {
9393
setState('error');
94-
setMsg((err as Error).message);
94+
const raw = (err as Error).message;
95+
setMsg(
96+
raw.includes('Orphan transaction')
97+
? 'A pending transaction is blocking this action. Wait for it to confirm, then try again.'
98+
: raw
99+
);
95100
}
96101
};
97102

0 commit comments

Comments
 (0)