diff --git a/src/app/ipo/ipo.component.html b/src/app/ipo/ipo.component.html index 42ed76f0..a2c55145 100644 --- a/src/app/ipo/ipo.component.html +++ b/src/app/ipo/ipo.component.html @@ -1,135 +1,230 @@ -
-

{{ t("ipoComponent.title") }}

-
- {{ t("ipoComponent.description") }} -
-
-
- -
-
- {{ t("general.loading") }} +
+

{{ t("ipoComponent.title") }}

+
+ {{ t("ipoComponent.description") }} +
+
+
+ +
+
+ {{ t("general.loading") }} +
+
+
+ + + {{ t("ipoComponent.loadError") }} +

+ +
+
+
+
+ + + {{ t("ipoComponent.noIpos") }} + + +
+
+ + +

{{ p.name }}

+ + + +
+ warning + {{ t("ipoComponent.bidLoadError") }} + +
+
+

{{ t("ipoComponent.yourShares") }}

+
+ ({{ + t("ipoComponent.statusValid", { + tick: getBidOverview(p.index).tick | number: "1.0-0", + }) + }})
-
-
- - - {{ t("ipoComponent.loadError") }} -

- -
-
-
-
- - - {{ t("ipoComponent.noIpos") }} - - -
-
- - -

{{ p.name }}

- - - -
- warning - {{ t("ipoComponent.bidLoadError") }} - -
-
-

{{ t("ipoComponent.yourShares") }}

-
({{ t("ipoComponent.statusValid", {tick: getBidOverview(p.index).tick | number: '1.0-0'}) }})
- -
- {{ t("ipoComponent.noShares") }} -
-
- {{ bid.bids.length }} ({{ getTotalPrice(bid.bids) | number: '1.0-0'}} {{ - t("general.currency") }}) {{ t("ipoComponent.sharesFor") }} - -
-
-
-
-

{{ t("ipoComponent.yourBids") }}

+ +
+ {{ t("ipoComponent.noShares") }} +
+
+ {{ bid.bids.length }} ({{ + getTotalPrice(bid.bids) | number: "1.0-0" + }} + {{ t("general.currency") }}) + {{ t("ipoComponent.sharesFor") }} + +
+
+
+
+

{{ t("ipoComponent.yourBids") }}

-
- warning - {{ t("ipoComponent.bidsLoadError") }} -
- -
- {{ t("ipoComponent.noBids") }} -
+
+ warning + {{ t("ipoComponent.bidsLoadError") }} +
+ +
+ {{ t("ipoComponent.noBids") }} +
-
- - - check_circle_outline - -
-
{{ transaction.bid.price | number: '1.0-0' }} - {{ - t("general.currency") }} / {{ transaction.bid.quantity | number: '1.0-0' }} {{ - t("ipoComponent.bidStatus.pcs") }}
-
Tick: {{ transaction.tickNumber | number: '1.0-0' }}
-
-
-
- arrow_downward - arrow_upward -
-
-
- From: {{ - getAddressDisplayName(transaction.source) }} -
-
- To: {{ - getAddressDisplayName(transaction.destination) }} -
-
-
-
- {{ +transaction.timestamp | dateTime }} -
-
-
+
+ + {{ + statusConfig.icon + }} + +
+
+ {{ transaction.bid.price | number: "1.0-0" }} + {{ t("general.currency") }} / + {{ transaction.bid.quantity | number: "1.0-0" }} + {{ t("ipoComponent.bidStatus.pcs") }} +
+
+ Tick: {{ transaction.tickNumber | number: "1.0-0" }} +
+
+
+
+ arrow_downward + arrow_upward +
+
+
+ From: + {{ getAddressDisplayName(transaction.source) }} +
+
+ To: + {{ + getAddressDisplayName(transaction.destination) + }}
- - -
+
+
+
+ {{ + +transaction.timestamp | dateTime + }} +
+ +
+ +
+
+
- \ No newline at end of file +
+ diff --git a/src/app/ipo/ipo.component.ts b/src/app/ipo/ipo.component.ts index 4a812594..f4bdb213 100644 --- a/src/app/ipo/ipo.component.ts +++ b/src/app/ipo/ipo.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { IpoContractsService } from '../services/ipo-contracts.service'; import { WalletService } from '../services/wallet.service'; -import { ContractDto, IpoBid, IpoBidOverview } from '../services/api.model'; +import { ContractDto, IpoBid, IpoBidOverview, PendingIpoBid } from '../services/api.model'; import { Router } from '@angular/router'; import { catchError, forkJoin, of, Subject } from 'rxjs'; import { map, switchMap, takeUntil } from 'rxjs/operators'; @@ -12,6 +12,11 @@ import { IpoBidsResponse } from '../services/apis/live/api.live.model'; import { CurrentIpoBidsContract } from '../services/apis/aggregation/api.aggregation.model'; import { ApiAggregationService } from '../services/apis/aggregation/api.aggregation.service'; import { ExplorerUrlHelper } from '../services/explorer-url.helper'; +import { PendingTransactionService } from '../services/pending-transaction.service'; +import { IPO_INPUT_TYPE } from '../constants/qubic.constants'; +import { decodeIpoInputHex } from '../utils/ipo-input.utils'; +import { mergeIpoBids } from '../utils/ipo-bid-merge.utils'; +import { getStatusConfig, TransactionStatusConfig } from '../helpers/transaction-status.helper'; @Component({ selector: 'app-ipo', @@ -28,7 +33,14 @@ export class IpoComponent implements OnInit, OnDestroy { public failedBidContracts = new Set(); public retryingContracts = new Set(); public ipoBids: CurrentIpoBidsContract[] = []; + public pendingIpoBids: Map = new Map(); public ipoBidsLoadError: boolean = false; + private trackedIpoTxIds = new Set(); + // Bids resolved on-chain but not yet returned by the aggregation API — kept + // rendered as 'pending' so they don't flicker out. Per-entry retry budget; + // pruned once confirmed or once the IPO leaves the active list. + private resolvedAwaitingConfirmation = new Map(); + private confirmRetryTimer: ReturnType | null = null; private destroy$ = new Subject(); constructor( @@ -37,9 +49,14 @@ export class IpoComponent implements OnInit, OnDestroy { private apiLiveService: ApiLiveService, private apiAggregationService: ApiAggregationService, private walletService: WalletService, - private addressNameService: AddressNameService + private addressNameService: AddressNameService, + private pendingTxService: PendingTransactionService ) { } ngOnDestroy(): void { + if (this.confirmRetryTimer) { + clearTimeout(this.confirmRetryTimer); + this.confirmRetryTimer = null; + } this.destroy$.next(); this.destroy$.complete(); } @@ -51,6 +68,57 @@ export class IpoComponent implements OnInit, OnDestroy { } this.init(); + + this.pendingTxService.pendingTransactions$ + .pipe(takeUntil(this.destroy$)) + .subscribe(pending => { + const map = new Map(); + const currentIpoTxIds = new Set(); + for (const tx of pending) { + if (tx.inputType !== IPO_INPUT_TYPE) continue; + if (!tx.inputHex) continue; + const contractIndex = Number(tx.destId); + if (!Number.isFinite(contractIndex)) continue; + currentIpoTxIds.add(tx.txId); + const decoded = decodeIpoInputHex(tx.inputHex); + if (!decoded) { + console.warn('Skipping pending IPO bid with undecodable input:', tx.txId); + continue; + } + const bid: PendingIpoBid = { + status: tx.isPending ? 'pending' : 'failed', + txId: tx.txId, + contractIndex, + source: tx.sourceId, + bid: decoded, + tickNumber: tx.tickNumber, + timestamp: String(tx.created.getTime()), + }; + const list = map.get(contractIndex) ?? []; + list.push(bid); + map.set(contractIndex, list); + } + // A tracked IPO tx gone from pending storage was confirmed on-chain + // (resolver deletes successes; failures stay isPending=false). Keep it + // visible and re-fetch — there is no background poll for IPO bids. + let resolved = false; + for (const id of this.trackedIpoTxIds) { + if (currentIpoTxIds.has(id)) continue; + resolved = true; + if (this.resolvedAwaitingConfirmation.has(id)) continue; + for (const list of this.pendingIpoBids.values()) { + const bid = list.find(b => b.txId === id); + if (bid && bid.status === 'pending') { + this.resolvedAwaitingConfirmation.set(id, { bid, attempts: 0 }); + } + } + } + this.pendingIpoBids = map; + this.trackedIpoTxIds = currentIpoTxIds; + if (resolved && !this.refreshing) { + this.init(); + } + }); } init() { @@ -96,7 +164,20 @@ export class IpoComponent implements OnInit, OnDestroy { const bidResponse = result.bidResponses[i]; return this.mapToContractDto(ipo.contractIndex, ipo.assetName, bidResponse); }); - this.ipoBids = result.bids; + // Union-merge, not replace: the aggregation API intermittently omits + // previously-returned transactions (see mergeIpoBids). + this.ipoBids = mergeIpoBids(this.ipoBids, result.bids); + + // Drop awaiting entries now confirmed, or whose IPO left the active list + // (their hash can never return, so they'd linger and burn retries). + const activeIndexes = new Set(result.activeIpos.map(ipo => ipo.contractIndex)); + const confirmedHashes = new Set(this.ipoBids.flatMap(c => c.transactions.map(t => t.hash))); + for (const [txId, entry] of this.resolvedAwaitingConfirmation) { + if (confirmedHashes.has(txId) || !activeIndexes.has(entry.bid.contractIndex)) { + this.resolvedAwaitingConfirmation.delete(txId); + } + } + this.scheduleConfirmRetryIfNeeded(); // Update BehaviorSubject for PlaceBidComponent this.ipoContractsService.set(this.ipoContracts); @@ -112,6 +193,28 @@ export class IpoComponent implements OnInit, OnDestroy { }); } + /** + * Background-retry the fetch while a resolved bid is missing from the + * aggregation response. Per-bid budget (3) so one stuck entry can't starve + * later ones; the bid stays rendered as 'pending' until observed. + */ + private scheduleConfirmRetryIfNeeded(): void { + if (this.confirmRetryTimer || this.resolvedAwaitingConfirmation.size === 0) { + return; + } + const entries = [...this.resolvedAwaitingConfirmation.values()]; + if (!entries.some(e => e.attempts < 3)) { + return; + } + entries.forEach(e => e.attempts++); + this.confirmRetryTimer = setTimeout(() => { + this.confirmRetryTimer = null; + if (!this.refreshing) { + this.init(); + } + }, 5000); + } + private mapToContractDto(contractIndex: number, assetName: string, bidResponse: IpoBidsResponse): ContractDto { const bidsMap = bidResponse?.bidData?.bids || {}; const ipoBids: IpoBid[] = Object.entries(bidsMap).map(([positionIndex, entry]) => ({ @@ -153,9 +256,35 @@ export class IpoComponent implements OnInit, OnDestroy { return this.walletService.getSeeds(); } - getContractBids(contractId: number) { - const contract = this.ipoBids.find(c => c.contractIndex === contractId); - return contract?.transactions || []; + // any[] = PendingIpoBid | IpoBidTransaction; strict template checking rejects + // the raw union over the pending/confirmed field differences. + getContractBids(contractId: number): any[] { + const confirmed = this.ipoBids.find(c => c.contractIndex === contractId)?.transactions ?? []; + const confirmedHashes = new Set(confirmed.map(t => t.hash)); + const pending = (this.pendingIpoBids.get(contractId) ?? []).filter(b => !confirmedHashes.has(b.txId)); + const pendingTxIds = new Set(pending.map(b => b.txId)); + const awaiting = [...this.resolvedAwaitingConfirmation.values()] + .map(e => e.bid) + .filter(b => b.contractIndex === contractId && !confirmedHashes.has(b.txId) && !pendingTxIds.has(b.txId)); + return [...pending, ...awaiting, ...confirmed]; + } + + /** + * Maps a bid row (pending/failed PendingIpoBid, or a status-less confirmed + * IpoBidTransaction) to the shared status config, matching the balance page. + */ + getBidStatusConfig(transaction: { status?: 'pending' | 'failed' }): TransactionStatusConfig { + if (transaction.status === 'pending') { + return getStatusConfig('trx-pending'); + } + if (transaction.status === 'failed') { + return getStatusConfig('trx-not-executed'); + } + return getStatusConfig('trx-executed'); + } + + dismissFailedBid(txId: string): void { + this.pendingTxService.removeFailedTransaction(txId); } hasBidError(contractIndex: number): boolean { diff --git a/src/app/services/api.model.ts b/src/app/services/api.model.ts index d5faac2c..53d3e0b0 100644 --- a/src/app/services/api.model.ts +++ b/src/app/services/api.model.ts @@ -46,6 +46,21 @@ export interface IpoBidOverview { bids: IpoBid[]; } +/** + * Locally-synthesized IPO bid view-model (not a wire response), built from + * PendingTransactionService entries. Mirrors IpoBidTransaction's template keys + * so both render with one *ngFor; `timestamp` is local submit time (epoch ms). + */ +export interface PendingIpoBid { + status: 'pending' | 'failed'; + txId: string; + contractIndex: number; + source: string; + bid: { price: number; quantity: number }; + tickNumber: number; + timestamp?: string; +} + export interface QubicAsset { publicId: string; contractIndex: number; diff --git a/src/app/services/transaction.service.ts b/src/app/services/transaction.service.ts index 1b8c6475..026ca292 100644 --- a/src/app/services/transaction.service.ts +++ b/src/app/services/transaction.service.ts @@ -9,6 +9,7 @@ import { QubicHelper } from '@qubic-lib/qubic-ts-library/dist/qubicHelper'; import { lastValueFrom } from 'rxjs'; import { IPO_INPUT_TYPE } from '../constants/qubic.constants'; import { bytesToHex } from '../utils/hex.utils'; +import { encodeIpoInputHex } from '../utils/ipo-input.utils'; /** * Transaction Service to send transaction to the qubic network @@ -107,7 +108,7 @@ export class TransactionService { amount: 0, tickNumber: targetTick, inputType: IPO_INPUT_TYPE, - inputHex: this.encodeIpoInputHex(price, quantity), + inputHex: encodeIpoInputHex(price, quantity), isPending: true, created: new Date(), }); @@ -127,21 +128,6 @@ export class TransactionService { } } - /** - * Builds the 16-byte IPO bid input payload (price + quantity + zero padding) - * and returns it as a lowercase hex string. Layout matches QubicHelper.createIpo: - * bytes 0-7 price (int64, little-endian) - * bytes 8-9 quantity (int16, little-endian) - * bytes 10-15 zero padding - */ - private encodeIpoInputHex(price: number, quantity: number): string { - const input = new Uint8Array(16); - const view = new DataView(input.buffer); - view.setBigInt64(0, BigInt(price), true); - view.setInt16(8, quantity, true); - return bytesToHex(input); - } - private storePendingTransaction(qtx: QubicTransaction): void { const payload = qtx.getPayload(); const inputHex = payload diff --git a/src/app/utils/hex.utils.ts b/src/app/utils/hex.utils.ts index 5c1a2920..fec8ee40 100644 --- a/src/app/utils/hex.utils.ts +++ b/src/app/utils/hex.utils.ts @@ -22,3 +22,22 @@ export function base64ToHex(base64: string): string { return ''; } } + +/** + * Decodes a hex string (case-insensitive, no 0x prefix) into bytes. + * Throws if the input has odd length or contains non-hex characters. + */ +export function hexToBytes(hex: string): Uint8Array { + if (hex.length === 0) return new Uint8Array(0); + if (hex.length % 2 !== 0) { + throw new Error(`hexToBytes: odd-length input (${hex.length})`); + } + if (!/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error(`hexToBytes: invalid hex character in input`); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} diff --git a/src/app/utils/ipo-bid-merge.utils.ts b/src/app/utils/ipo-bid-merge.utils.ts new file mode 100644 index 00000000..e43fd3c6 --- /dev/null +++ b/src/app/utils/ipo-bid-merge.utils.ts @@ -0,0 +1,27 @@ +import { CurrentIpoBidsContract } from '../services/apis/aggregation/api.aggregation.model'; + +/** + * Union-merge getCurrentIpoBids responses per contract, new-wins by tx hash. + * Confirmed on-chain txs are immutable, so a hash missing from a later response + * is backend flakiness, not a removal — keeping the merged list monotone stops + * the flaky endpoint flickering bids out. Null/undefined `transactions` (Go + * nil-slice JSON) is treated as empty. + */ +export function mergeIpoBids( + previous: CurrentIpoBidsContract[], + incoming: CurrentIpoBidsContract[], +): CurrentIpoBidsContract[] { + const byIndex = new Map(); + for (const contract of previous) { + byIndex.set(contract.contractIndex, { ...contract, transactions: contract.transactions ?? [] }); + } + for (const next of incoming) { + const prev = byIndex.get(next.contractIndex); + const merged = new Map((prev?.transactions ?? []).map(t => [t.hash, t])); + for (const tx of next.transactions ?? []) { + merged.set(tx.hash, tx); + } + byIndex.set(next.contractIndex, { ...next, transactions: [...merged.values()] }); + } + return [...byIndex.values()]; +} diff --git a/src/app/utils/ipo-input.utils.ts b/src/app/utils/ipo-input.utils.ts new file mode 100644 index 00000000..dc0f8502 --- /dev/null +++ b/src/app/utils/ipo-input.utils.ts @@ -0,0 +1,30 @@ +import { bytesToHex, hexToBytes } from './hex.utils'; + +/** + * 16-byte IPO bid payload as lowercase hex (matches QubicHelper.createIpo): + * price int64 LE @0, quantity int16 LE @8, bytes 10-15 zero. + */ +export function encodeIpoInputHex(price: number, quantity: number): string { + const input = new Uint8Array(16); + const view = new DataView(input.buffer); + view.setBigInt64(0, BigInt(price), true); + view.setInt16(8, quantity, true); + return bytesToHex(input); +} + +/** Reverse of encodeIpoInputHex; null on empty/short/non-hex input (skip the entry). */ +export function decodeIpoInputHex(hex: string): { price: number; quantity: number } | null { + if (!hex) return null; + let bytes: Uint8Array; + try { + bytes = hexToBytes(hex); + } catch { + return null; + } + if (bytes.length < 10) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + price: Number(view.getBigInt64(0, true)), + quantity: view.getInt16(8, true), + }; +}