No push event exists for Ponder indexing completion; grandfathered with justification comment per the no-fixed-delays rule exception policy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
353 lines
13 KiB
Vue
353 lines
13 KiB
Vue
<template>
|
|
<FCollapse class="f-collapse-active" @collapse:opened="loadActivePositionData" :loading="loading">
|
|
<template v-slot:header>
|
|
<div class="collapse-header">
|
|
<div class="collapse-header-row1">
|
|
<div><span class="subheader2">Tax</span> {{ props.taxRate }} %</div>
|
|
<FButton size="tiny" @click="payTax(props.id)">Pay Tax</FButton>
|
|
<div class="position-id">
|
|
<span class="subheader2">ID</span> <span class="number-small">{{ props.id }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="collapse-header-row2">
|
|
<div>
|
|
<div class="profit-stats-item">
|
|
<div><b>Initial Stake</b></div>
|
|
<div>{{ compactNumber(props.amount) }} $KRK</div>
|
|
</div>
|
|
</div>
|
|
<div class="tags-list">
|
|
<FTag v-if="tag">{{ tag }}</FTag>
|
|
</div>
|
|
</div>
|
|
<div class="pnl-metrics">
|
|
<div class="pnl-line1" :class="{ 'pnl-positive': netReturn > 0, 'pnl-negative': netReturn < 0 }">
|
|
Gross: {{ formatPercent(grossReturn) }} · Tax: {{ formatPercent(-taxCostPercent) }} · Net: {{ formatPercent(netReturn) }}
|
|
</div>
|
|
<div class="pnl-line2">
|
|
Held {{ timeHeldFormatted }} · Snatched {{ props.position.snatched }} time{{ props.position.snatched === 1 ? '' : 's' }}
|
|
</div>
|
|
</div>
|
|
<!-- <div class="collapse-amount">
|
|
<span class="number-small">{{ compactNumber(props.amount) }}</span>
|
|
<span class="caption"> $KRK</span>
|
|
</div> -->
|
|
</div>
|
|
</template>
|
|
<div class="collapsed-body">
|
|
<div v-if="error" class="collapsed-body--error">
|
|
<span>{{ error }}</span>
|
|
<FButton size="tiny" outlined @click="loadActivePositionData">Retry</FButton>
|
|
</div>
|
|
<div v-if="!error || taxPaidGes !== undefined" class="profit-stats-wrapper">
|
|
<div class="profit-stats-item">
|
|
<div><b>Tax Paid</b></div>
|
|
<div>{{ taxPaidGes !== undefined ? formatTokenAmount(taxPaidGes) : '...' }} $KRK</div>
|
|
</div>
|
|
<div class="profit-stats-item">
|
|
<div><b>Issuance Earned</b></div>
|
|
<div>{{ profit !== undefined ? formatTokenAmount(profit) : '...' }} $KRK</div>
|
|
</div>
|
|
<div class="profit-stats-item profit-stats-total">
|
|
<div><b>Total</b></div>
|
|
<div>{{ taxPaidGes !== undefined && profit !== undefined ? formatTokenAmount(total) : '...' }} $KRK</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="collapsed-body--actions">
|
|
<div :class="{ 'collapse-menu-open': showTaxMenu }">
|
|
<FButton size="small" dense block outlined v-if="adjustTaxRate.state === 'SignTransaction'">Sign Transaction ...</FButton>
|
|
<FButton size="small" dense outlined block v-else-if="adjustTaxRate.state === 'Waiting'" @click="unstakePosition"
|
|
>Waiting ...</FButton
|
|
>
|
|
<FButton size="small" dense block v-else-if="adjustTaxRate.state === 'Action' && !showTaxMenu" @click="showTaxMenu = true"
|
|
>Adjust Tax Rate</FButton
|
|
>
|
|
<template v-else>
|
|
<div class="collapse-menu-input">
|
|
<FSelect :items="filteredTaxRates" v-model="newTaxRateIndex"> </FSelect>
|
|
</div>
|
|
<div>
|
|
<FButton size="small" dense @click="changeTax(props.id, newTaxRateIndex)">Confirm</FButton>
|
|
<FButton size="small" dense outlined @click="showTaxMenu = false">Cancel</FButton>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div></div>
|
|
<div>
|
|
<FButton size="small" dense block outlined v-if="unstake.state === 'SignTransaction'">Sign Transaction ...</FButton>
|
|
<FButton size="small" dense outlined block v-else-if="unstake.state === 'Waiting'">Waiting ...</FButton>
|
|
<FButton size="small" dense block v-else-if="unstake.state === 'Unstakeable'" @click="unstakePosition">Unstake</FButton>
|
|
</div>
|
|
</div>
|
|
</FCollapse>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FButton from '@/components/fcomponents/FButton.vue';
|
|
import FTag from '@/components/fcomponents/FTag.vue';
|
|
import FSelect from '@/components/fcomponents/FSelect.vue';
|
|
import FCollapse from '@/components/fcomponents/FCollapse.vue';
|
|
import { compactNumber, weiToNumber, formatTokenAmount } from 'kraiken-lib/format';
|
|
import { useUnstake } from '@/composables/useUnstake';
|
|
import { useAdjustTaxRate } from '@/composables/useAdjustTaxRates';
|
|
import { computed, ref, onMounted } from 'vue';
|
|
import { getTaxDue, payTax } from '@/contracts/stake';
|
|
import { type Position, loadPositions } from '@/composables/usePositions';
|
|
import { useStatCollection } from '@/composables/useStatCollection';
|
|
import { useWallet } from '@/composables/useWallet';
|
|
import { DEFAULT_CHAIN_ID } from '@/config';
|
|
import { calculateActivePositionProfit } from 'kraiken-lib/position';
|
|
|
|
const unstake = useUnstake();
|
|
const adjustTaxRate = useAdjustTaxRate();
|
|
const wallet = useWallet();
|
|
const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID;
|
|
const statCollection = useStatCollection(initialChainId);
|
|
const currentChainId = computed(() => wallet.account.chainId ?? DEFAULT_CHAIN_ID);
|
|
|
|
const props = defineProps<{
|
|
taxRate: number;
|
|
taxRateIndex?: number;
|
|
tresholdIndex: number;
|
|
id: bigint;
|
|
amount: number;
|
|
position: Position;
|
|
}>();
|
|
|
|
const showTaxMenu = ref(false);
|
|
const newTaxRateIndex = ref<number | null>(null);
|
|
const taxDue = ref<bigint>(0n);
|
|
const taxPaidGes = ref<number>();
|
|
const profit = ref<number>();
|
|
const loading = ref<boolean>(false);
|
|
const error = ref<string | null>(null);
|
|
|
|
const tag = computed(() => {
|
|
// Compare by index instead of decimal to avoid floating-point issues
|
|
const idx = props.taxRateIndex ?? props.position.taxRateIndex;
|
|
if (typeof idx === 'number' && idx < props.tresholdIndex) {
|
|
return 'Low Tax!';
|
|
}
|
|
return '';
|
|
});
|
|
|
|
const total = computed(() => props.amount + (profit.value ?? 0) - (taxPaidGes.value ?? 0));
|
|
|
|
// P&L calculations (FIXED: Use BigInt math to preserve precision)
|
|
const grossReturn = computed(() => {
|
|
try {
|
|
const currentSupply = BigInt(statCollection.kraikenTotalSupply);
|
|
const initSupply = BigInt(props.position.totalSupplyInit);
|
|
|
|
if (initSupply === 0n) return 0;
|
|
|
|
// Calculate percentage change using BigInt to avoid precision loss
|
|
// Formula: ((current - init) / init) * 100
|
|
// To maintain precision, multiply by 10000 first, then divide by 100 for display
|
|
const diff = currentSupply - initSupply;
|
|
const percentBigInt = (diff * 10000n) / initSupply;
|
|
|
|
return Number(percentBigInt) / 100;
|
|
} catch (error) {
|
|
void error; // suppress lint
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
const taxCostPercent = computed(() => {
|
|
try {
|
|
const taxPaid = BigInt(props.position.taxPaid);
|
|
const deposit = BigInt(props.position.harbDeposit);
|
|
|
|
if (deposit === 0n) return 0;
|
|
|
|
// Calculate percentage using BigInt precision
|
|
const percentBigInt = (taxPaid * 10000n) / deposit;
|
|
|
|
return Number(percentBigInt) / 100;
|
|
} catch (error) {
|
|
void error; // suppress lint
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
const netReturn = computed(() => {
|
|
return grossReturn.value - taxCostPercent.value;
|
|
});
|
|
|
|
const timeHeldFormatted = computed(() => {
|
|
if (!props.position.creationTime) return '0d 0h';
|
|
// Handle both Date objects and bigint/number timestamps
|
|
let creationTimestamp: number;
|
|
if (props.position.creationTime instanceof Date) {
|
|
creationTimestamp = Math.floor(props.position.creationTime.getTime() / 1000);
|
|
} else {
|
|
creationTimestamp = Number(props.position.creationTime);
|
|
}
|
|
const nowTimestamp = Math.floor(Date.now() / 1000);
|
|
const secondsHeld = nowTimestamp - creationTimestamp;
|
|
const days = Math.floor(secondsHeld / 86400);
|
|
const hours = Math.floor((secondsHeld % 86400) / 3600);
|
|
return `${days}d ${hours}h`;
|
|
});
|
|
|
|
function formatPercent(value: number): string {
|
|
const sign = value >= 0 ? '+' : '';
|
|
return `${sign}${value.toFixed(1)}%`;
|
|
}
|
|
|
|
const filteredTaxRates = computed(() => {
|
|
const currentIndex = props.position.taxRateIndex ?? -1;
|
|
return adjustTaxRate.taxRates.filter(option => option.index > currentIndex);
|
|
});
|
|
|
|
async function changeTax(id: bigint, nextTaxRateIndex: number | null) {
|
|
if (typeof nextTaxRateIndex !== 'number') {
|
|
return;
|
|
}
|
|
await adjustTaxRate.changeTax(id, nextTaxRateIndex);
|
|
showTaxMenu.value = false;
|
|
}
|
|
|
|
async function unstakePosition() {
|
|
await unstake.exitPosition(props.id);
|
|
loading.value = true;
|
|
// eslint-disable-next-line no-restricted-syntax -- Polling with timeout: no push event exists for Ponder indexing completion; Ponder GraphQL has no subscription endpoint. See AGENTS.md #Engineering Principles.
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
await loadPositions(currentChainId.value);
|
|
loading.value = false;
|
|
}
|
|
|
|
async function loadActivePositionData() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
//loadTaxDue
|
|
taxDue.value = await getTaxDue(props.id);
|
|
taxPaidGes.value = weiToNumber(taxDue.value + props.position.taxPaid);
|
|
|
|
//loadTotalSupply
|
|
|
|
// Calculate issuance earned using kraiken-lib profit calculation
|
|
profit.value = calculateActivePositionProfit(props.position.totalSupplyInit, statCollection.kraikenTotalSupply, props.position.share);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to load active position data:', err);
|
|
error.value = 'Failed to load position data.';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
const availableRates = filteredTaxRates.value;
|
|
if (availableRates.length > 0) {
|
|
newTaxRateIndex.value = availableRates[0]?.index ?? null;
|
|
} else if (typeof props.position.taxRateIndex === 'number') {
|
|
newTaxRateIndex.value = props.position.taxRateIndex;
|
|
} else {
|
|
newTaxRateIndex.value = adjustTaxRate.taxRates[0]?.index ?? null;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style lang="sass">
|
|
@use 'collapse'
|
|
.f-collapse
|
|
&.f-collapse-active
|
|
background-color: #07111B
|
|
.collapse-header
|
|
width: 100%
|
|
display: flex
|
|
justify-content: space-between
|
|
flex-direction: column
|
|
position: relative
|
|
gap: 16px
|
|
.collapse-header-row1
|
|
display: flex
|
|
flex-direction: row
|
|
gap: 16px
|
|
align-items: center
|
|
// margin-right: 32px
|
|
|
|
.position-id
|
|
margin-left: auto
|
|
.collapse-header-row2
|
|
display: flex
|
|
justify-content: space-between
|
|
>div
|
|
width: 50%
|
|
min-height: 22px
|
|
.profit-stats-item
|
|
display: grid
|
|
grid-template-columns: 1fr 1fr
|
|
gap: 8px
|
|
div
|
|
&:last-child
|
|
text-align: right
|
|
.tags-list
|
|
margin-right: 32px
|
|
text-align: right
|
|
.pnl-metrics
|
|
display: flex
|
|
flex-direction: column
|
|
gap: 4px
|
|
padding-top: 8px
|
|
font-size: 13px
|
|
.pnl-line1
|
|
font-weight: 500
|
|
&.pnl-positive
|
|
color: #4ade80
|
|
&.pnl-negative
|
|
color: #f87171
|
|
.pnl-line2
|
|
color: #9A9898
|
|
font-size: 12px
|
|
.collapsableDiv
|
|
.collapsed-body
|
|
display: flex
|
|
flex-direction: column
|
|
margin-right: 32px
|
|
.collapsed-body--header
|
|
justify-content: space-between
|
|
.profit-stats-wrapper
|
|
display: flex
|
|
|
|
flex-direction: column
|
|
gap: 4px
|
|
margin: 0
|
|
margin-top: 4px
|
|
width: 100%
|
|
color: white
|
|
@media (min-width: 768px)
|
|
width: 50%
|
|
margin: 4px auto 0 0
|
|
.profit-stats-item
|
|
display: grid
|
|
grid-template-columns: 1fr 1fr
|
|
gap: 8px
|
|
div
|
|
&:last-child
|
|
text-align: right
|
|
&.profit-stats-total
|
|
border-top: 2px solid var(--color-font)
|
|
padding-top: 2px
|
|
.collapsed-body--error
|
|
display: flex
|
|
flex-wrap: wrap
|
|
align-items: center
|
|
gap: 8px
|
|
color: #f87171
|
|
font-size: 13px
|
|
margin-bottom: 8px
|
|
.collapsed-body--actions
|
|
.collapse-menu-open
|
|
display: flex
|
|
align-items: center
|
|
gap: 4px
|
|
flex: 1 1 auto
|
|
.collapse-menu-input
|
|
display: flex
|
|
align-items: center
|
|
</style>
|