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
132 changes: 132 additions & 0 deletions src/api/pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,138 @@ import { address, poolBalances, bufferBalances, poolStakes, poolStatsDaily, pool
import { getAssetAddress, getAssetAddresses, getLabelForAsset, getChainData } from '@lib/utils'
import { showToast, showError } from '@lib/ui'

const POOL_HISTORY_EVENTS = ['PoolDeposit', 'PoolWithdrawal', 'PoolPayIn', 'PoolPayOut'];
const POOL_HISTORY_BLOCK_SPAN = 10000;
const POOL_HISTORY_PAGE_SIZE = 50;
const blockTimestampCache = new Map();

function getPoolTransactionType(eventName) {
return {
PoolDeposit: 'deposit',
PoolWithdrawal: 'withdrawal',
PoolPayIn: 'pay in',
PoolPayOut: 'pay out'
}[eventName];
}

function isBeforeCursor(item, cursor) {
if (!cursor) return true;
if (item.blockNumber < cursor.blockNumber) return true;
return item.blockNumber == cursor.blockNumber && item.logIndex < cursor.logIndex;
}

function formatPoolTransaction(log, parsedLog) {
const args = parsedLog.args;
const asset = getLabelForAsset(args.asset) || args.asset;
const decimals = CURRENCY_DECIMALS[asset] || 18;
const explorer = getChainData('explorer');
const logIndex = log.logIndex == undefined ? 0 : log.logIndex;

const transaction = {
id: `${log.transactionHash}:${logIndex}`,
type: getPoolTransactionType(parsedLog.name),
user: args.user,
asset,
market: args.market || '',
amount: formatUnits(args.amount, decimals),
poolBalance: formatUnits(args.poolBalance, decimals),
blockNumber: log.blockNumber,
logIndex,
timestamp: 0,
transactionHash: log.transactionHash,
explorerUrl: explorer ? `${explorer}/tx/${log.transactionHash}` : ''
};

if (args.bufferBalance != undefined) {
transaction.bufferBalance = formatUnits(args.bufferBalance, decimals);
}

return transaction;
}

async function getBlockTimestamp(provider, blockNumber) {
if (blockTimestampCache.has(blockNumber)) return blockTimestampCache.get(blockNumber);
try {
const block = await provider.getBlock(blockNumber);
const timestamp = block && block.timestamp ? block.timestamp : 0;
blockTimestampCache.set(blockNumber, timestamp);
return timestamp;
} catch (e) {
blockTimestampCache.set(blockNumber, 0);
return 0;
}
}

export async function getPoolTransactions(params = {}) {
const contract = await getContract('Pool');
if (!contract || !contract.provider || !contract.address) {
return {items: [], nextCursor: null, hasMore: false};
}

const pageSize = Math.max(1, Math.min(Number(params.pageSize) || POOL_HISTORY_PAGE_SIZE, 100));
const provider = contract.provider;
const latestBlock = await provider.getBlockNumber();
const suppliedCursor = params.cursor;
let cursor = suppliedCursor && Number.isInteger(Number(suppliedCursor.blockNumber))
? {blockNumber: Number(suppliedCursor.blockNumber), logIndex: Number(suppliedCursor.logIndex) || 0}
: null;
let toBlock = cursor ? Math.min(cursor.blockNumber, latestBlock) : latestBlock;
let reachedStart = false;
let transactions = [];
const eventTopics = POOL_HISTORY_EVENTS.map((eventName) => contract.interface.getEventTopic(eventName));

while (transactions.length < pageSize && toBlock >= 0) {
const fromBlock = Math.max(0, toBlock - POOL_HISTORY_BLOCK_SPAN + 1);
const logs = await provider.getLogs({
address: contract.address,
fromBlock,
toBlock,
topics: [eventTopics]
});

for (const log of logs) {
try {
const parsedLog = contract.interface.parseLog(log);
if (!parsedLog || !getPoolTransactionType(parsedLog.name)) continue;
const transaction = formatPoolTransaction(log, parsedLog);
if (isBeforeCursor(transaction, cursor)) transactions.push(transaction);
} catch (e) {
// Ignore logs that cannot be decoded by this contract ABI.
}
}

if (fromBlock == 0) {
reachedStart = true;
break;
}

toBlock = fromBlock - 1;
// The cursor only applies to the first block range. Older ranges are unfiltered.
cursor = null;
}

transactions.sort((a, b) => {
if (a.blockNumber != b.blockNumber) return b.blockNumber - a.blockNumber;
return b.logIndex - a.logIndex;
});

const items = transactions.slice(0, pageSize);
const blockNumbers = [...new Set(items.map((item) => item.blockNumber))];
const timestamps = await Promise.all(blockNumbers.map(async (blockNumber) => {
return [blockNumber, await getBlockTimestamp(provider, blockNumber)];
}));
const timestampByBlock = new Map(timestamps);
for (const item of items) item.timestamp = timestampByBlock.get(item.blockNumber) || 0;

const lastItem = items[items.length - 1];
const hasMore = !reachedStart || transactions.length > pageSize;
return {
items,
nextCursor: hasMore && lastItem ? {blockNumber: lastItem.blockNumber, logIndex: lastItem.logIndex} : null,
hasMore
};
}

export async function getPoolBalances() {
const contract = await getContract('PoolStore');
const assetAddresses = getAssetAddresses();
Expand Down
251 changes: 251 additions & 0 deletions src/components/pool/PoolTransactions.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<script>
import { onMount } from 'svelte'
import Button from '@components/layout/Button.svelte'
import { LOADING_ICON } from '@lib/icons'
import { chainId } from '@lib/stores'
import { formatDate, numberWithCommas } from '@lib/formatters'
import { shortAddress } from '@lib/utils'
import { getPoolTransactions } from '@api/pool'

const PAGE_SIZE = 50;

let transactions = [];
let nextCursor = null;
let hasMore = true;
let isLoading = false;
let error = '';
let sentinel;
let observer;
let mounted = false;
let loadedChain;
let requestId = 0;

async function loadTransactions(reset = false) {
if (isLoading || (!reset && !hasMore)) return;
isLoading = true;
error = '';
const currentRequest = ++requestId;
if (reset) {
transactions = [];
nextCursor = null;
hasMore = true;
}

try {
const result = await getPoolTransactions({
cursor: reset ? null : nextCursor,
pageSize: PAGE_SIZE
});
if (currentRequest != requestId) return;
const existingIds = new Set(transactions.map((item) => item.id));
const newItems = result.items.filter((item) => !existingIds.has(item.id));
transactions = reset ? result.items : transactions.concat(newItems);
nextCursor = result.nextCursor;
hasMore = result.hasMore;
} catch (e) {
if (currentRequest == requestId) error = 'Unable to load pool transactions. Please try again.';
console.error('Pool transaction history error', e);
} finally {
if (currentRequest == requestId) isLoading = false;
}
}

$: if (mounted && $chainId && loadedChain != $chainId) {
loadedChain = $chainId;
loadTransactions(true);
}

onMount(() => {
mounted = true;
if ($chainId) {
loadedChain = $chainId;
loadTransactions(true);
}
observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadTransactions(false);
}, {rootMargin: '240px'});
if (sentinel) observer.observe(sentinel);

return () => {
requestId++;
if (observer) observer.disconnect();
};
});
</script>

<style>
.transactions {
margin-top: 38px;
}

.header {
margin-bottom: 16px;
}

.title {
font-weight: 600;
font-size: 20px;
padding-bottom: 6px;
}

.subtitle, .muted, .empty {
color: var(--text300);
font-size: 85%;
}

.table-wrapper {
overflow-x: auto;
border: 1px solid var(--layer100);
border-radius: 6px;
}

.table {
min-width: 900px;
}

.table-header, .row {
display: grid;
align-items: center;
grid-template-columns: 0.9fr 0.65fr 0.8fr 1fr 1.05fr 1.05fr 1.2fr 0.85fr;
}

.table-header {
height: 38px;
border-bottom: 1px solid var(--layer100);
color: var(--text300);
font-size: 85%;
}

.row {
min-height: 50px;
border-bottom: 1px solid var(--layer0-hover);
}

.row:last-child {
border-bottom: none;
}

.cell {
min-width: 0;
overflow: hidden;
padding: 8px 16px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}

.cell.left {
text-align: left;
}

.type {
font-weight: 600;
text-transform: capitalize;
}

.type.deposit { color: var(--primary); }
.type.withdrawal, .type.pay-out { color: var(--secondary); }
.type.pay-in { color: var(--text100); }

.grayed {
color: var(--text300);
display: block;
font-size: 80%;
}

a {
color: var(--primary);
text-decoration: none;
}

.footer {
display: flex;
align-items: center;
justify-content: center;
min-height: 62px;
padding: 8px;
}

.loading {
color: var(--text300);
}

.loading :global(svg) {
fill: currentColor;
width: 22px;
}

.error, .empty {
border: 1px solid var(--layer100);
border-radius: 6px;
padding: 24px;
text-align: center;
}

.error {
color: var(--secondary);
}

.error button {
margin-left: 12px;
color: var(--primary);
background: transparent;
text-decoration: underline;
}

.sentinel {
height: 1px;
}
</style>

<div class='transactions'>
<div class='header'>
<div class='title'>Pool transactions</div>
<div class='subtitle'>Deposits, withdrawals, trader pay-ins, and trader pay-outs.</div>
</div>

{#if error}
<div class='error'>{error}<button type='button' on:click={() => loadTransactions(true)}>Retry</button></div>
{:else if transactions.length == 0 && !isLoading}
<div class='empty'>No pool transactions found.</div>
{:else}
<div class='table-wrapper'>
<div class='table'>
<div class='table-header'>
<div class='cell left'>Type</div>
<div class='cell left'>Asset</div>
<div class='cell left'>Market</div>
<div class='cell'>Amount</div>
<div class='cell'>Pool balance</div>
<div class='cell'>Buffer</div>
<div class='cell left'>User / time</div>
<div class='cell'>Transaction</div>
</div>
{#each transactions as item (item.id)}
<div class='row'>
<div class={`cell left type ${item.type.replace(' ', '-')}`}>{item.type}</div>
<div class='cell left'>{item.asset}</div>
<div class='cell left'>{item.market || '-'}</div>
<div class='cell'>{numberWithCommas(item.amount)} {item.asset}</div>
<div class='cell'>{numberWithCommas(item.poolBalance)} {item.asset}</div>
<div class='cell'>{item.bufferBalance == undefined ? '-' : `${numberWithCommas(item.bufferBalance)} ${item.asset}`}</div>
<div class='cell left'><span>{shortAddress(item.user)}<span class='grayed'>{item.timestamp ? formatDate(item.timestamp) : `Block ${item.blockNumber}`}</span></span></div>
<div class='cell'>{#if item.explorerUrl}<a href={item.explorerUrl} target='_blank' rel='noreferrer'>{item.transactionHash.slice(0, 6)}…</a>{:else}-{/if}</div>
</div>
{/each}
</div>
</div>

<div class='footer'>
{#if isLoading}
<span class='loading'>{@html LOADING_ICON}</span>
{:else if hasMore}
<Button isSmall={true} label='Load more' on:click={() => loadTransactions(false)} />
{:else}
<span class='muted'>All available transactions loaded.</span>
{/if}
</div>
{/if}

<div class='sentinel' bind:this={sentinel}></div>
</div>
Loading