- {{ t("general.loading") }}
+
+
{{ t("ipoComponent.title") }}
+
+ {{ t("ipoComponent.description") }}
+
+
+
+
+
+
+ {{ t("general.loading") }}
+
+
+
+
+
+ {{ t("ipoComponent.loadError") }}
+
+
+
+
+
+
+
+
+ {{ t("ipoComponent.noIpos") }}
+
+
+
+
0">
+
+
+ {{ 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") }}
-
-
-
- 0">
-
-
- {{ 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),
+ };
+}