Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 224 additions & 129 deletions src/app/ipo/ipo.component.html

Large diffs are not rendered by default.

141 changes: 135 additions & 6 deletions src/app/ipo/ipo.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand All @@ -28,7 +33,14 @@ export class IpoComponent implements OnInit, OnDestroy {
public failedBidContracts = new Set<number>();
public retryingContracts = new Set<number>();
public ipoBids: CurrentIpoBidsContract[] = [];
public pendingIpoBids: Map<number, PendingIpoBid[]> = new Map();
public ipoBidsLoadError: boolean = false;
private trackedIpoTxIds = new Set<string>();
// 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<string, { bid: PendingIpoBid; attempts: number }>();
private confirmRetryTimer: ReturnType<typeof setTimeout> | null = null;
private destroy$ = new Subject<void>();

constructor(
Expand All @@ -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();
}
Expand All @@ -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<number, PendingIpoBid[]>();
const currentIpoTxIds = new Set<string>();
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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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]) => ({
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src/app/services/api.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 2 additions & 16 deletions src/app/services/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
});
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/app/utils/hex.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
27 changes: 27 additions & 0 deletions src/app/utils/ipo-bid-merge.utils.ts
Original file line number Diff line number Diff line change
@@ -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<number, CurrentIpoBidsContract>();
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()];
}
30 changes: 30 additions & 0 deletions src/app/utils/ipo-input.utils.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
Loading