@@ -11,6 +11,7 @@ interface StoredToken {
1111 ticker : string ;
1212 decimals : number ;
1313 issuedAt : number ;
14+ txId ?: string ;
1415}
1516
1617type 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
3742const LS_KEY = 'ml_issued_tokens' ;
@@ -45,7 +50,10 @@ function loadStored(): StoredToken[] {
4550}
4651
4752function 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
5159async 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 ) ;
0 commit comments